mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 11:46:07 +00:00
1294 lines
47 KiB
JavaScript
1294 lines
47 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { lstatSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
import { basename, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { gunzipSync, inflateRawSync } from "node:zlib";
|
|
import {
|
|
downloadActionsArtifactArchive,
|
|
describeActionsArtifactFiles,
|
|
inspectActionsArtifactZip,
|
|
inspectActionsArtifactZipWithPolicy,
|
|
readBoundedRegularFile,
|
|
readPublicationArtifactArchive,
|
|
sha256Digest,
|
|
validateActionsArtifactBinding,
|
|
validateActionsArtifactProducerJob,
|
|
} from "./lib/actions-artifact-archive.mjs";
|
|
import { resolveNpmPublishPlan } from "./lib/npm-publish-plan.mjs";
|
|
|
|
export {
|
|
downloadActionsArtifactArchive,
|
|
describeActionsArtifactFiles,
|
|
inspectActionsArtifactZip,
|
|
inspectActionsArtifactZipWithPolicy,
|
|
readBoundedRegularFile,
|
|
readPublicationArtifactArchive,
|
|
validateActionsArtifactBinding,
|
|
validateActionsArtifactProducerJob,
|
|
};
|
|
|
|
const MANIFEST_FILENAME = "plugin-publication-manifest.json";
|
|
const MANIFEST_SCHEMA = "openclaw.plugin-publication-artifact/v1";
|
|
const TAR_BLOCK_BYTES = 512;
|
|
const TAR_END_MARKER_BYTES = TAR_BLOCK_BYTES * 2;
|
|
const TAR_USTAR_MAGIC = Buffer.from("ustar\0", "ascii");
|
|
const TAR_USTAR_VERSION = Buffer.from("00", "ascii");
|
|
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
|
const MAX_EXPANDED_BYTES = 512 * 1024 * 1024;
|
|
const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
|
|
const MAX_PLUGIN_MANIFEST_BYTES = 2 * 1024 * 1024;
|
|
const MAX_TAR_ENTRIES = 10_000;
|
|
const MAX_TAR_PATH_BYTES = 4 * 1024 * 1024;
|
|
const MAX_TAR_TOTAL_FILE_BYTES = 512 * 1024 * 1024;
|
|
export const CLAWHUB_PUBLICATION_TAR_LIMITS = Object.freeze({
|
|
maxArchiveBytes: 120 * 1024 * 1024,
|
|
maxEntries: 10_000,
|
|
maxEntryBytes: 50 * 1024 * 1024,
|
|
maxExpandedBytes: 64 * 1024 * 1024,
|
|
maxPathBytes: 4 * 1024 * 1024,
|
|
maxTotalFileBytes: 50 * 1024 * 1024,
|
|
});
|
|
const SHA_RE = /^[0-9a-f]{40}$/u;
|
|
const SHA256_RE = /^[0-9a-f]{64}$/u;
|
|
const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u;
|
|
const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
|
|
const VERSION_RE =
|
|
/^[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:alpha|beta)\.[1-9][0-9]*|-[1-9][0-9]*)?$/u;
|
|
const NPM_ROUTE_POLICIES = new Map([
|
|
["npm-oidc", { authMode: "trusted-publisher", capability: "trusted-publisher" }],
|
|
["npm-token-bootstrap", { authMode: "token-bootstrap", capability: "first-publication" }],
|
|
[
|
|
"npm-token-placeholder-recovery",
|
|
{ authMode: "token-bootstrap", capability: "placeholder-recovery" },
|
|
],
|
|
["npm-mirror", { authMode: "release-token", capability: "dist-tag-mirror" }],
|
|
["npm-tag-repair", { authMode: "release-token", capability: "dist-tag-repair" }],
|
|
["npm-readback", { authMode: "none", capability: "registry-readback" }],
|
|
]);
|
|
const ROUTES = new Set([
|
|
...NPM_ROUTE_POLICIES.keys(),
|
|
"clawhub-token-release",
|
|
"clawhub-token-bootstrap",
|
|
"clawhub-readback",
|
|
]);
|
|
const NPM_TAGS = new Set(["latest", "alpha", "beta", "extended-stable"]);
|
|
const CLAWHUB_TAGS = new Set(["latest", "alpha", "beta"]);
|
|
const META_PACKAGE = "@openclaw/meta-provider";
|
|
const META_PACKAGE_DIR = "extensions/meta";
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function npmIntegrity(bytes) {
|
|
return `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
}
|
|
|
|
function npmShasum(bytes) {
|
|
return createHash("sha1").update(bytes).digest("hex");
|
|
}
|
|
|
|
function compareCodeUnits(left, right) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function assertString(value, label) {
|
|
if (typeof value !== "string" || value.trim() !== value || value.length === 0) {
|
|
throw new Error(`${label} must be a non-empty trimmed string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertPositiveInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error(`${label} must be a safe positive integer.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertBooleanString(value, label) {
|
|
if (value === "true") {
|
|
return true;
|
|
}
|
|
if (value === "false") {
|
|
return false;
|
|
}
|
|
throw new Error(`${label} must be true or false.`);
|
|
}
|
|
|
|
function hasControlCharacters(value) {
|
|
for (const character of value) {
|
|
const codePoint = character.codePointAt(0);
|
|
if (codePoint <= 0x1f || codePoint === 0x7f) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeManualOverrideReason(value) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return null;
|
|
}
|
|
const reason = assertString(value, "manual override reason");
|
|
if (reason.length > 500 || hasControlCharacters(reason)) {
|
|
throw new Error(
|
|
"Manual override reason must be at most 500 characters and contain no control characters.",
|
|
);
|
|
}
|
|
return reason;
|
|
}
|
|
|
|
function assertSafePackageDir(value) {
|
|
const packageDir = assertString(value, "package dir");
|
|
if (!/^extensions\/[a-z0-9][a-z0-9._-]*$/u.test(packageDir) || packageDir.includes("\\")) {
|
|
throw new Error(`Unsafe plugin package dir: ${packageDir}`);
|
|
}
|
|
return packageDir;
|
|
}
|
|
|
|
function assertSafeArtifactName(value) {
|
|
const artifactName = assertString(value, "artifact name");
|
|
if (!ARTIFACT_NAME_RE.test(artifactName)) {
|
|
throw new Error(`Unsafe plugin publication artifact name: ${artifactName}`);
|
|
}
|
|
return artifactName;
|
|
}
|
|
|
|
function assertSafeArchivePath(value, label) {
|
|
const raw = assertString(value, label);
|
|
if (
|
|
raw.startsWith("/") ||
|
|
raw.includes("\\") ||
|
|
raw.includes("\0") ||
|
|
raw.normalize("NFC") !== raw ||
|
|
hasControlCharacters(raw)
|
|
) {
|
|
throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`);
|
|
}
|
|
const withoutTrailingSlash = raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
const parts = withoutTrailingSlash.split("/");
|
|
if (
|
|
withoutTrailingSlash.length === 0 ||
|
|
parts.some((part) => part.length === 0 || part === "." || part === "..")
|
|
) {
|
|
throw new Error(`Unsafe ${label}: ${JSON.stringify(raw)}`);
|
|
}
|
|
return withoutTrailingSlash;
|
|
}
|
|
|
|
function normalizePublicationReason(value) {
|
|
const reason = assertString(value, "publication reason");
|
|
if (reason.length > 500 || hasControlCharacters(reason)) {
|
|
throw new Error(
|
|
"Publication reason must be at most 500 characters and contain no control characters.",
|
|
);
|
|
}
|
|
return reason;
|
|
}
|
|
|
|
function normalizePublisherPolicy(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("Publisher policy must be an object.");
|
|
}
|
|
const keys = Object.keys(value).toSorted();
|
|
if (JSON.stringify(keys) !== JSON.stringify(["policyId", "schema", "sha256"])) {
|
|
throw new Error("Publisher policy must contain exactly schema, policyId, and sha256.");
|
|
}
|
|
const schema = assertString(value.schema, "publisher policy schema");
|
|
const policyId = assertString(value.policyId, "publisher policy id");
|
|
const policySha256 = assertString(value.sha256, "publisher policy SHA-256");
|
|
if (
|
|
schema.length > 200 ||
|
|
policyId.length > 200 ||
|
|
hasControlCharacters(schema) ||
|
|
hasControlCharacters(policyId) ||
|
|
!SHA256_RE.test(policySha256)
|
|
) {
|
|
throw new Error("Publisher policy identity is invalid.");
|
|
}
|
|
return { policyId, schema, sha256: policySha256 };
|
|
}
|
|
|
|
function boundedTarLimit(value, fallback, label) {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
if (!Number.isSafeInteger(value) || value <= 0 || value > fallback) {
|
|
throw new Error(`${label} must be a positive safe integer no larger than ${fallback}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeTarInspectionOptions(options = {}) {
|
|
const maxArchiveBytes = boundedTarLimit(
|
|
options.maxArchiveBytes,
|
|
MAX_ARCHIVE_BYTES,
|
|
"Plugin tarball byte limit",
|
|
);
|
|
const maxExpandedBytes = boundedTarLimit(
|
|
options.maxExpandedBytes,
|
|
MAX_EXPANDED_BYTES,
|
|
"Plugin tarball expanded-byte limit",
|
|
);
|
|
const maxEntryBytes = boundedTarLimit(
|
|
options.maxEntryBytes,
|
|
maxExpandedBytes,
|
|
"Plugin tarball per-entry byte limit",
|
|
);
|
|
const maxTotalFileBytes = boundedTarLimit(
|
|
options.maxTotalFileBytes,
|
|
Math.min(MAX_TAR_TOTAL_FILE_BYTES, maxExpandedBytes),
|
|
"Plugin tarball total-file byte limit",
|
|
);
|
|
const maxEntries = boundedTarLimit(
|
|
options.maxEntries,
|
|
MAX_TAR_ENTRIES,
|
|
"Plugin tarball entry-count limit",
|
|
);
|
|
const maxPathBytes = boundedTarLimit(
|
|
options.maxPathBytes,
|
|
MAX_TAR_PATH_BYTES,
|
|
"Plugin tarball path-byte limit",
|
|
);
|
|
return {
|
|
maxArchiveBytes,
|
|
maxEntries,
|
|
maxEntryBytes,
|
|
maxExpandedBytes,
|
|
maxPathBytes,
|
|
maxTotalFileBytes,
|
|
};
|
|
}
|
|
|
|
function decodeTarString(bytes, label = "tar string field") {
|
|
const nul = bytes.indexOf(0);
|
|
if (nul !== -1 && bytes.subarray(nul + 1).some((byte) => byte !== 0)) {
|
|
throw new Error(`${label} has non-zero bytes after its NUL terminator.`);
|
|
}
|
|
const value = bytes.subarray(0, nul === -1 ? bytes.length : nul);
|
|
try {
|
|
return new TextDecoder("utf-8", { fatal: true }).decode(value);
|
|
} catch {
|
|
throw new Error(`${label} is not valid UTF-8.`);
|
|
}
|
|
}
|
|
|
|
function decodeConsumerTarPathField(bytes, label) {
|
|
const raw = decodeTarString(bytes, label);
|
|
const consumerValue = raw.trim();
|
|
if (raw !== consumerValue) {
|
|
throw new Error(`${label} changes under the pinned ClawHub path normalization.`);
|
|
}
|
|
return consumerValue;
|
|
}
|
|
|
|
function normalizeConsumerTarPath(value, options = {}) {
|
|
const rawPath = options.directory === true && value.endsWith("/") ? value.slice(0, -1) : value;
|
|
const normalized = rawPath.replaceAll("\\", "/").replace(/^\.\/+/u, "");
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
const consumerPath = segments.join("/");
|
|
if (
|
|
consumerPath !== rawPath ||
|
|
segments.length === 0 ||
|
|
segments.some((segment) => segment === "." || segment === "..")
|
|
) {
|
|
throw new Error(
|
|
`Tar entry path changes under the pinned ClawHub normalization: ${JSON.stringify(value)}.`,
|
|
);
|
|
}
|
|
return assertSafeArchivePath(consumerPath, "tar entry path");
|
|
}
|
|
|
|
function parseCanonicalTarNumber(bytes, label, options = {}) {
|
|
const { allowEmpty = false } = options;
|
|
if ((bytes[0] & 0x80) !== 0) {
|
|
throw new Error(`${label} must not use base-256 encoding.`);
|
|
}
|
|
if (bytes.every((byte) => byte === 0)) {
|
|
if (allowEmpty) {
|
|
return 0;
|
|
}
|
|
throw new Error(`${label} must not be empty.`);
|
|
}
|
|
const raw = bytes.toString("ascii");
|
|
const canonicalDigits = new RegExp(`^[0-7]{${bytes.length - 2}}$`, "u");
|
|
const canonical = canonicalDigits.test(raw.slice(0, -2)) && raw.endsWith(" \0");
|
|
if (!canonical) {
|
|
throw new Error(`${label} is not canonically encoded.`);
|
|
}
|
|
const octal = raw.slice(0, -2);
|
|
const value = Number.parseInt(octal, 8);
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new Error(`Invalid ${label}: ${JSON.stringify(octal)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function verifyTarChecksum(header) {
|
|
const expected = parseCanonicalTarNumber(header.subarray(148, 156), "tar checksum");
|
|
let actual = 0;
|
|
for (let index = 0; index < header.length; index += 1) {
|
|
actual += index >= 148 && index < 156 ? 0x20 : header[index];
|
|
}
|
|
if (actual !== expected) {
|
|
throw new Error(`Tar header checksum mismatch: expected ${expected}, got ${actual}.`);
|
|
}
|
|
}
|
|
|
|
function verifyCanonicalTarHeader(header) {
|
|
if (
|
|
!header.subarray(257, 263).equals(TAR_USTAR_MAGIC) ||
|
|
!header.subarray(263, 265).equals(TAR_USTAR_VERSION)
|
|
) {
|
|
throw new Error("Plugin tarball entries must use canonical POSIX USTAR headers.");
|
|
}
|
|
decodeTarString(header.subarray(0, 100), "tar entry name");
|
|
parseCanonicalTarNumber(header.subarray(100, 108), "tar entry mode");
|
|
parseCanonicalTarNumber(header.subarray(108, 116), "tar entry uid", { allowEmpty: true });
|
|
parseCanonicalTarNumber(header.subarray(116, 124), "tar entry gid", { allowEmpty: true });
|
|
parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size");
|
|
parseCanonicalTarNumber(header.subarray(136, 148), "tar entry mtime");
|
|
decodeTarString(header.subarray(157, 257), "tar entry link path");
|
|
decodeTarString(header.subarray(265, 297), "tar entry user name");
|
|
decodeTarString(header.subarray(297, 329), "tar entry group name");
|
|
parseCanonicalTarNumber(header.subarray(329, 337), "tar entry device major");
|
|
parseCanonicalTarNumber(header.subarray(337, 345), "tar entry device minor");
|
|
if (header[475] === 0) {
|
|
decodeTarString(header.subarray(345, 475), "tar entry prefix");
|
|
parseCanonicalTarNumber(header.subarray(476, 488), "tar entry access time", {
|
|
allowEmpty: true,
|
|
});
|
|
parseCanonicalTarNumber(header.subarray(488, 500), "tar entry change time", {
|
|
allowEmpty: true,
|
|
});
|
|
} else {
|
|
decodeTarString(header.subarray(345, 500), "tar entry prefix");
|
|
}
|
|
}
|
|
|
|
function isZeroTarBlock(bytes) {
|
|
return bytes.length === TAR_BLOCK_BYTES && bytes.every((byte) => byte === 0);
|
|
}
|
|
|
|
function parsePackedJson(bytes, label) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function firstGzipMemberEnd(bytes, maxOutputLength) {
|
|
if (
|
|
bytes.length < 18 ||
|
|
bytes[0] !== 0x1f ||
|
|
bytes[1] !== 0x8b ||
|
|
bytes[2] !== 0x08 ||
|
|
(bytes[3] & 0xe0) !== 0
|
|
) {
|
|
throw new Error("Invalid gzip header.");
|
|
}
|
|
|
|
const flags = bytes[3];
|
|
let offset = 10;
|
|
if ((flags & 0x04) !== 0) {
|
|
if (offset + 2 > bytes.length) {
|
|
throw new Error("Truncated gzip extra-field length.");
|
|
}
|
|
const extraLength = bytes.readUInt16LE(offset);
|
|
offset += 2 + extraLength;
|
|
if (offset > bytes.length) {
|
|
throw new Error("Truncated gzip extra field.");
|
|
}
|
|
}
|
|
for (const flag of [0x08, 0x10]) {
|
|
if ((flags & flag) === 0) {
|
|
continue;
|
|
}
|
|
const terminator = bytes.indexOf(0, offset);
|
|
if (terminator === -1) {
|
|
throw new Error("Unterminated gzip header string.");
|
|
}
|
|
offset = terminator + 1;
|
|
}
|
|
if ((flags & 0x02) !== 0) {
|
|
offset += 2;
|
|
}
|
|
if (offset + 8 > bytes.length) {
|
|
throw new Error("Truncated gzip member.");
|
|
}
|
|
|
|
const expanded = inflateRawSync(bytes.subarray(offset), {
|
|
info: true,
|
|
maxOutputLength,
|
|
});
|
|
return offset + expanded.engine.bytesWritten + 8;
|
|
}
|
|
|
|
export function inspectPackageTarballBytes(inputBytes, options = {}) {
|
|
if (!(inputBytes instanceof Uint8Array)) {
|
|
throw new Error("Plugin tarball bytes must be a Uint8Array.");
|
|
}
|
|
const tarballBytes = Buffer.from(inputBytes.buffer, inputBytes.byteOffset, inputBytes.byteLength);
|
|
const limits = normalizeTarInspectionOptions(options);
|
|
if (tarballBytes.length === 0 || tarballBytes.length > limits.maxArchiveBytes) {
|
|
throw new Error(`Plugin tarball size is outside the allowed range: ${tarballBytes.length}.`);
|
|
}
|
|
let memberEnd;
|
|
try {
|
|
memberEnd = firstGzipMemberEnd(tarballBytes, limits.maxExpandedBytes);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (memberEnd !== tarballBytes.length) {
|
|
throw new Error("Plugin tarball must contain exactly one gzip member.");
|
|
}
|
|
let tarBytes;
|
|
try {
|
|
const expanded = gunzipSync(tarballBytes, {
|
|
info: true,
|
|
maxOutputLength: limits.maxExpandedBytes,
|
|
});
|
|
if (expanded.engine.bytesWritten !== tarballBytes.length) {
|
|
throw new Error("gzip stream does not consume the full plugin tarball");
|
|
}
|
|
tarBytes = expanded.buffer;
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Plugin tarball is not canonical gzip or expands beyond ${limits.maxExpandedBytes} bytes.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
const inventory = [];
|
|
const seenPaths = new Set();
|
|
const seenAliases = new Set();
|
|
let packageManifestBytes;
|
|
let pluginManifestBytes;
|
|
let offset = 0;
|
|
let sawEndMarker = false;
|
|
let entryCount = 0;
|
|
let totalFileBytes = 0;
|
|
let totalPathBytes = 0;
|
|
|
|
while (offset + TAR_BLOCK_BYTES <= tarBytes.length) {
|
|
const header = tarBytes.subarray(offset, offset + TAR_BLOCK_BYTES);
|
|
if (isZeroTarBlock(header)) {
|
|
const secondEndBlock = tarBytes.subarray(
|
|
offset + TAR_BLOCK_BYTES,
|
|
offset + TAR_END_MARKER_BYTES,
|
|
);
|
|
if (
|
|
!isZeroTarBlock(secondEndBlock) ||
|
|
!tarBytes.subarray(offset + TAR_END_MARKER_BYTES).every((byte) => byte === 0)
|
|
) {
|
|
throw new Error(
|
|
"Plugin tarball must end with two zero blocks and contain no trailing entries.",
|
|
);
|
|
}
|
|
sawEndMarker = true;
|
|
break;
|
|
}
|
|
entryCount += 1;
|
|
if (entryCount > limits.maxEntries) {
|
|
throw new Error(`Plugin tarball exceeds the ${limits.maxEntries} entry limit.`);
|
|
}
|
|
verifyTarChecksum(header);
|
|
verifyCanonicalTarHeader(header);
|
|
const headerName = decodeConsumerTarPathField(header.subarray(0, 100), "tar entry name");
|
|
const headerPrefix =
|
|
header[475] === 0
|
|
? decodeConsumerTarPathField(header.subarray(345, 475), "tar entry prefix")
|
|
: decodeConsumerTarPathField(header.subarray(345, 500), "tar entry prefix");
|
|
const headerPath = headerPrefix ? `${headerPrefix}/${headerName}` : headerName;
|
|
const headerSize = parseCanonicalTarNumber(header.subarray(124, 136), "tar entry size");
|
|
const typeFlag = String.fromCharCode(header[156] || 0x30);
|
|
const linkPath = decodeTarString(header.subarray(157, 257), "tar entry link path");
|
|
if (typeFlag === "x" || typeFlag === "g" || typeFlag === "L" || typeFlag === "K") {
|
|
throw new Error("PAX and GNU tar metadata are not supported for plugin artifacts.");
|
|
}
|
|
if (typeFlag !== "0" && typeFlag !== "5") {
|
|
const suffix = linkPath ? ` -> ${linkPath}` : "";
|
|
throw new Error(
|
|
`Unsupported plugin tar entry type ${JSON.stringify(typeFlag)}: ${headerPath}${suffix}`,
|
|
);
|
|
}
|
|
if (typeFlag === "5" && headerSize !== 0) {
|
|
throw new Error(`Directory tar entry ${JSON.stringify(headerPath)} must have size zero.`);
|
|
}
|
|
if (linkPath) {
|
|
throw new Error(
|
|
`Plugin tar entries must not carry link targets: ${headerPath} -> ${linkPath}`,
|
|
);
|
|
}
|
|
if (typeFlag !== "5" && headerPath.endsWith("/")) {
|
|
throw new Error(
|
|
`Non-directory tar entry must not end with a slash: ${JSON.stringify(headerPath)}.`,
|
|
);
|
|
}
|
|
|
|
const safePath = normalizeConsumerTarPath(headerPath, { directory: typeFlag === "5" });
|
|
if (safePath !== "package" && !safePath.startsWith("package/")) {
|
|
throw new Error(`Plugin tar entry must stay under package/: ${safePath}`);
|
|
}
|
|
const alias = safePath.toLocaleLowerCase("en-US");
|
|
if (seenPaths.has(safePath) || seenAliases.has(alias)) {
|
|
throw new Error(`Duplicate or aliased plugin tar entry: ${safePath}`);
|
|
}
|
|
seenPaths.add(safePath);
|
|
seenAliases.add(alias);
|
|
totalPathBytes += Buffer.byteLength(safePath, "utf8");
|
|
if (totalPathBytes > limits.maxPathBytes) {
|
|
throw new Error(`Plugin tarball paths exceed the ${limits.maxPathBytes} byte limit.`);
|
|
}
|
|
|
|
if (headerSize > limits.maxEntryBytes) {
|
|
throw new Error(`Plugin tar entry ${safePath} exceeds ${limits.maxEntryBytes} bytes.`);
|
|
}
|
|
const dataOffset = offset + TAR_BLOCK_BYTES;
|
|
const dataEnd = dataOffset + headerSize;
|
|
const nextOffset = dataOffset + Math.ceil(headerSize / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES;
|
|
if (dataEnd > tarBytes.length || nextOffset > tarBytes.length) {
|
|
throw new Error(`Tar entry ${JSON.stringify(safePath)} exceeds the archive boundary.`);
|
|
}
|
|
const content = tarBytes.subarray(dataOffset, dataEnd);
|
|
offset = nextOffset;
|
|
|
|
if (typeFlag === "5") {
|
|
inventory.push({ path: safePath, sizeBytes: 0, type: "directory" });
|
|
continue;
|
|
}
|
|
totalFileBytes += content.length;
|
|
if (totalFileBytes > limits.maxTotalFileBytes) {
|
|
throw new Error(`Plugin tarball file payload exceeds ${limits.maxTotalFileBytes} bytes.`);
|
|
}
|
|
const entry = {
|
|
path: safePath,
|
|
sha256: sha256(content),
|
|
sizeBytes: content.length,
|
|
type: "file",
|
|
};
|
|
inventory.push(entry);
|
|
if (safePath === "package/package.json") {
|
|
if (content.length === 0 || content.length > MAX_MANIFEST_BYTES) {
|
|
throw new Error(
|
|
`Packed package.json size is outside the allowed range: ${content.length}.`,
|
|
);
|
|
}
|
|
packageManifestBytes = Buffer.from(content);
|
|
} else if (safePath === "package/openclaw.plugin.json") {
|
|
if (content.length === 0 || content.length > MAX_PLUGIN_MANIFEST_BYTES) {
|
|
throw new Error(
|
|
`Packed openclaw.plugin.json size is outside the allowed range: ${content.length}.`,
|
|
);
|
|
}
|
|
pluginManifestBytes = Buffer.from(content);
|
|
}
|
|
}
|
|
|
|
if (!sawEndMarker) {
|
|
throw new Error("Plugin tarball is missing its end marker.");
|
|
}
|
|
if (!packageManifestBytes) {
|
|
throw new Error("Plugin tarball must contain exactly one package/package.json.");
|
|
}
|
|
if (!pluginManifestBytes) {
|
|
throw new Error("Plugin tarball must contain exactly one package/openclaw.plugin.json.");
|
|
}
|
|
inventory.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
const packageManifest = parsePackedJson(packageManifestBytes, "Packed package.json");
|
|
const pluginManifest = parsePackedJson(pluginManifestBytes, "Packed openclaw.plugin.json");
|
|
return {
|
|
inventory,
|
|
packageManifest,
|
|
packageManifestSha256: sha256(packageManifestBytes),
|
|
pluginManifest,
|
|
pluginManifestSha256: sha256(pluginManifestBytes),
|
|
tarballSizeBytes: tarballBytes.byteLength,
|
|
tarballSha256: sha256(tarballBytes),
|
|
totalFileBytes,
|
|
};
|
|
}
|
|
|
|
export function validatePluginPackageManifest(params, packageManifest) {
|
|
if (packageManifest.name !== params.packageName || packageManifest.version !== params.version) {
|
|
throw new Error(
|
|
`Packed plugin identity ${String(packageManifest.name)}@${String(packageManifest.version)} does not match ${params.packageName}@${params.version}.`,
|
|
);
|
|
}
|
|
if (Object.hasOwn(packageManifest, "tag")) {
|
|
throw new Error(
|
|
`${params.packageName}: packed package.json must not override the approved publication tag.`,
|
|
);
|
|
}
|
|
const release = packageManifest.openclaw?.release;
|
|
const referencesMetaIdentity =
|
|
params.packageName === META_PACKAGE || params.packageDir === META_PACKAGE_DIR;
|
|
if (
|
|
referencesMetaIdentity &&
|
|
(params.packageName !== META_PACKAGE ||
|
|
params.packageDir !== META_PACKAGE_DIR ||
|
|
release?.publishToNpm !== true ||
|
|
release?.publishToClawHub !== true)
|
|
) {
|
|
throw new Error(
|
|
`${META_PACKAGE}: Meta publication requires ${META_PACKAGE_DIR} with npm and ClawHub enabled.`,
|
|
);
|
|
}
|
|
if (params.route.startsWith("npm-") && release?.publishToNpm !== true) {
|
|
throw new Error(`${params.packageName}: packed plugin is not marked publishToNpm.`);
|
|
}
|
|
if (params.route.startsWith("clawhub-") && release?.publishToClawHub !== true) {
|
|
throw new Error(`${params.packageName}: packed plugin is not marked publishToClawHub.`);
|
|
}
|
|
if (packageManifest.publishConfig !== undefined) {
|
|
throw new Error(
|
|
`${params.packageName}: packed package.json must not override publication through publishConfig.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizePublicationParams(params) {
|
|
const targetSha = assertString(params.targetSha, "target SHA");
|
|
if (!SHA_RE.test(targetSha)) {
|
|
throw new Error(`Target SHA must be a full lowercase commit SHA: ${targetSha}`);
|
|
}
|
|
const packageDir = assertSafePackageDir(params.packageDir);
|
|
const packageName = assertString(params.packageName, "package name");
|
|
if (!PACKAGE_NAME_RE.test(packageName)) {
|
|
throw new Error(`Invalid plugin package name: ${packageName}`);
|
|
}
|
|
const version = assertString(params.version, "package version");
|
|
if (!VERSION_RE.test(version)) {
|
|
throw new Error(`Invalid plugin package version: ${version}`);
|
|
}
|
|
const route = assertString(params.route, "publication route");
|
|
if (!ROUTES.has(route)) {
|
|
throw new Error(`Unsupported plugin publication route: ${route}`);
|
|
}
|
|
const npmRoutePolicy = NPM_ROUTE_POLICIES.get(route);
|
|
let publicationReason = null;
|
|
let publisherPolicy = null;
|
|
if (npmRoutePolicy) {
|
|
if (params.authMode !== undefined && params.authMode !== npmRoutePolicy.authMode) {
|
|
throw new Error(`${route} auth mode must be ${npmRoutePolicy.authMode}.`);
|
|
}
|
|
if (params.capability !== undefined && params.capability !== npmRoutePolicy.capability) {
|
|
throw new Error(`${route} capability must be ${npmRoutePolicy.capability}.`);
|
|
}
|
|
publicationReason = normalizePublicationReason(params.publicationReason);
|
|
publisherPolicy = normalizePublisherPolicy(params.publisherPolicy);
|
|
} else if (
|
|
params.authMode !== undefined ||
|
|
params.capability !== undefined ||
|
|
params.publicationReason !== undefined ||
|
|
params.publisherPolicy !== undefined
|
|
) {
|
|
throw new Error(`${route} must not carry npm publisher-policy controls.`);
|
|
}
|
|
const publishTag = assertString(params.publishTag, "publish tag");
|
|
const allowedTags = route.startsWith("npm-") ? NPM_TAGS : CLAWHUB_TAGS;
|
|
if (!allowedTags.has(publishTag)) {
|
|
throw new Error(`Unsupported ${route} publish tag: ${publishTag}`);
|
|
}
|
|
if (route.startsWith("npm-")) {
|
|
const override = publishTag === "extended-stable" ? publishTag : undefined;
|
|
const publishPlan = resolveNpmPublishPlan(version, undefined, override);
|
|
if (publishPlan.publishTag !== publishTag) {
|
|
throw new Error(
|
|
`${packageName}@${version}: npm publish tag ${publishTag} does not match release channel ${publishPlan.channel}.`,
|
|
);
|
|
}
|
|
} else {
|
|
const expectedTag = version.includes("-alpha.")
|
|
? "alpha"
|
|
: version.includes("-beta.")
|
|
? "beta"
|
|
: "latest";
|
|
if (publishTag !== expectedTag) {
|
|
throw new Error(
|
|
`${packageName}@${version}: ClawHub publish tag ${publishTag} must be ${expectedTag}.`,
|
|
);
|
|
}
|
|
}
|
|
const artifactName = assertSafeArtifactName(params.artifactName);
|
|
let bootstrapMode = null;
|
|
let requiresManualOverride = false;
|
|
const manualOverrideReason = normalizeManualOverrideReason(params.manualOverrideReason);
|
|
if (route === "clawhub-token-bootstrap") {
|
|
bootstrapMode = assertString(params.bootstrapMode, "bootstrap mode");
|
|
if (bootstrapMode !== "publish" && bootstrapMode !== "configure-only") {
|
|
throw new Error(`Unsupported ClawHub bootstrap mode: ${bootstrapMode}`);
|
|
}
|
|
requiresManualOverride = params.requiresManualOverride === true;
|
|
} else if (route === "clawhub-token-release") {
|
|
requiresManualOverride = params.requiresManualOverride === true;
|
|
} else if (
|
|
params.bootstrapMode !== undefined ||
|
|
params.requiresManualOverride === true ||
|
|
manualOverrideReason !== null
|
|
) {
|
|
throw new Error(`${route} must not carry ClawHub bootstrap controls.`);
|
|
}
|
|
if (requiresManualOverride !== (manualOverrideReason !== null)) {
|
|
throw new Error(
|
|
`${route} must bind a manual override reason exactly when a manual override is required.`,
|
|
);
|
|
}
|
|
let sourcePackageJsonSha256;
|
|
if (params.sourcePackageJsonSha256 !== undefined) {
|
|
sourcePackageJsonSha256 = assertString(
|
|
params.sourcePackageJsonSha256,
|
|
"source package.json SHA-256",
|
|
);
|
|
if (!SHA256_RE.test(sourcePackageJsonSha256)) {
|
|
throw new Error(
|
|
`Source package.json SHA-256 must be 64 lowercase hex characters: ${sourcePackageJsonSha256}`,
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
artifactName,
|
|
authMode: npmRoutePolicy?.authMode ?? null,
|
|
bootstrapMode,
|
|
capability: npmRoutePolicy?.capability ?? null,
|
|
manualOverrideReason,
|
|
packageDir,
|
|
packageName,
|
|
publicationReason,
|
|
publishTag,
|
|
publisherPolicy,
|
|
requiresManualOverride,
|
|
route,
|
|
sourcePackageJsonSha256,
|
|
targetSha,
|
|
version,
|
|
};
|
|
}
|
|
|
|
function buildManifest(params, tarballName, tarballBytes, inspection) {
|
|
validatePluginPackageManifest(params, inspection.packageManifest);
|
|
const publication = params.authMode
|
|
? {
|
|
route: params.route,
|
|
authMode: params.authMode,
|
|
capability: params.capability,
|
|
reason: params.publicationReason,
|
|
tag: params.publishTag,
|
|
publisherPolicy: params.publisherPolicy,
|
|
}
|
|
: {
|
|
route: params.route,
|
|
tag: params.publishTag,
|
|
bootstrapMode: params.bootstrapMode,
|
|
manualOverrideReason: params.manualOverrideReason,
|
|
requiresManualOverride: params.requiresManualOverride,
|
|
};
|
|
return {
|
|
schema: MANIFEST_SCHEMA,
|
|
schemaVersion: 1,
|
|
targetSha: params.targetSha,
|
|
package: {
|
|
dir: params.packageDir,
|
|
name: params.packageName,
|
|
version: params.version,
|
|
author: inspection.packageManifest.author ?? null,
|
|
contributors: inspection.packageManifest.contributors ?? null,
|
|
repository: inspection.packageManifest.repository ?? null,
|
|
packageJsonSha256: inspection.packageManifestSha256,
|
|
pluginManifestSha256: inspection.pluginManifestSha256,
|
|
sourcePackageJsonSha256: params.sourcePackageJsonSha256,
|
|
},
|
|
publication,
|
|
artifact: {
|
|
name: params.artifactName,
|
|
tarball: tarballName,
|
|
npmIntegrity: npmIntegrity(tarballBytes),
|
|
npmShasum: npmShasum(tarballBytes),
|
|
sha256: inspection.tarballSha256,
|
|
sizeBytes: tarballBytes.length,
|
|
inventory: inspection.inventory,
|
|
},
|
|
};
|
|
}
|
|
|
|
function tarInspectionOptionsForRoute(route) {
|
|
return route.startsWith("clawhub-") ? CLAWHUB_PUBLICATION_TAR_LIMITS : undefined;
|
|
}
|
|
|
|
function normalizeExpectedInventory(value) {
|
|
if (!Array.isArray(value)) {
|
|
throw new Error("Expected plugin tarball inventory must be an array.");
|
|
}
|
|
const paths = new Set();
|
|
const aliases = new Set();
|
|
const inventory = value.map((entry) => {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
throw new Error("Expected plugin tarball inventory entries must be objects.");
|
|
}
|
|
const expectedKeys =
|
|
entry.type === "directory"
|
|
? ["path", "sizeBytes", "type"]
|
|
: ["path", "sha256", "sizeBytes", "type"];
|
|
if (JSON.stringify(Object.keys(entry).toSorted()) !== JSON.stringify(expectedKeys)) {
|
|
throw new Error("Expected plugin tarball inventory entry shape is invalid.");
|
|
}
|
|
const path = assertSafeArchivePath(entry.path, "expected tar entry path");
|
|
if (path !== entry.path) {
|
|
throw new Error(`Expected plugin tarball path is not canonical: ${entry.path}`);
|
|
}
|
|
const alias = path.toLocaleLowerCase("en-US");
|
|
if (paths.has(path) || aliases.has(alias)) {
|
|
throw new Error(`Expected plugin tarball inventory contains an alias: ${path}`);
|
|
}
|
|
paths.add(path);
|
|
aliases.add(alias);
|
|
if (
|
|
!Number.isSafeInteger(entry.sizeBytes) ||
|
|
entry.sizeBytes < 0 ||
|
|
(entry.type === "directory" && entry.sizeBytes !== 0)
|
|
) {
|
|
throw new Error(`Expected plugin tarball size is invalid for ${path}.`);
|
|
}
|
|
if (entry.type === "directory") {
|
|
return { path, sizeBytes: 0, type: "directory" };
|
|
}
|
|
if (
|
|
entry.type !== "file" ||
|
|
typeof entry.sha256 !== "string" ||
|
|
!SHA256_RE.test(entry.sha256)
|
|
) {
|
|
throw new Error(`Expected plugin tarball file identity is invalid for ${path}.`);
|
|
}
|
|
return {
|
|
path,
|
|
sha256: entry.sha256,
|
|
sizeBytes: entry.sizeBytes,
|
|
type: "file",
|
|
};
|
|
});
|
|
return inventory.toSorted((left, right) => compareCodeUnits(left.path, right.path));
|
|
}
|
|
|
|
function canonicalManifestText(manifest) {
|
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
}
|
|
|
|
function createFreshOutputDirectory(path, label) {
|
|
try {
|
|
lstatSync(path);
|
|
throw new Error(`${label} must not already exist.`);
|
|
} catch (error) {
|
|
if (!error || typeof error !== "object" || error.code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
const created = lstatSync(path);
|
|
if (!created.isDirectory() || created.isSymbolicLink()) {
|
|
throw new Error(`${label} must be a newly created directory.`);
|
|
}
|
|
}
|
|
|
|
export function createPluginPublicationArtifact(params) {
|
|
const normalized = normalizePublicationParams(params);
|
|
if (!normalized.sourcePackageJsonSha256) {
|
|
throw new Error("Plugin publication creation requires source package.json SHA-256.");
|
|
}
|
|
const artifactDir = resolve(params.artifactDir);
|
|
const entries = readdirSync(artifactDir, { withFileTypes: true });
|
|
const tarballs = entries.filter(
|
|
(entry) => entry.isFile() && entry.name.endsWith(".tgz") && entry.name !== MANIFEST_FILENAME,
|
|
);
|
|
const unexpected = entries.filter((entry) => !(entry.isFile() && entry.name.endsWith(".tgz")));
|
|
if (tarballs.length !== 1 || unexpected.length !== 0) {
|
|
throw new Error(
|
|
`Plugin publication staging dir must contain exactly one .tgz and no other entries; found ${entries.map((entry) => entry.name).join(", ") || "<empty>"}.`,
|
|
);
|
|
}
|
|
const tarballName = assertSafeArchivePath(tarballs[0].name, "tarball filename");
|
|
if (basename(tarballName) !== tarballName) {
|
|
throw new Error(`Plugin tarball must be at the artifact root: ${tarballName}`);
|
|
}
|
|
const tarballPath = join(artifactDir, tarballName);
|
|
const tarballBytes = readBoundedRegularFile(tarballPath, {
|
|
label: "Plugin tarball",
|
|
maxBytes: MAX_ARCHIVE_BYTES,
|
|
});
|
|
const inspection = inspectPackageTarballBytes(
|
|
tarballBytes,
|
|
tarInspectionOptionsForRoute(normalized.route),
|
|
);
|
|
const manifest = buildManifest(normalized, tarballName, tarballBytes, inspection);
|
|
const manifestPath = join(artifactDir, MANIFEST_FILENAME);
|
|
writeFileSync(manifestPath, canonicalManifestText(manifest), { mode: 0o600 });
|
|
return { manifest, manifestPath, tarballPath };
|
|
}
|
|
|
|
function parseBoundedJsonFile(path, label, maxBytes = MAX_MANIFEST_BYTES) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(
|
|
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
readBoundedRegularFile(path, {
|
|
label,
|
|
maxBytes,
|
|
}),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function inspectPluginPublicationArtifactZip(zipBytes) {
|
|
return inspectActionsArtifactZipWithPolicy(zipBytes, {
|
|
minEntries: 2,
|
|
maxEntries: 2,
|
|
maxArchiveBytes: MAX_ARCHIVE_BYTES,
|
|
maxExpandedBytes: MAX_ARCHIVE_BYTES + MAX_MANIFEST_BYTES,
|
|
allowPath: (name) =>
|
|
name === MANIFEST_FILENAME || (basename(name) === name && name.endsWith(".tgz")),
|
|
maxEntryBytes: (name) => (name === MANIFEST_FILENAME ? MAX_MANIFEST_BYTES : MAX_ARCHIVE_BYTES),
|
|
});
|
|
}
|
|
|
|
export function verifyPluginPublicationArtifact(params) {
|
|
const normalized = normalizePublicationParams(params);
|
|
let expectedTarballSha256;
|
|
if (params.expectedTarballSha256 !== undefined) {
|
|
expectedTarballSha256 = assertString(params.expectedTarballSha256, "expected tarball SHA-256");
|
|
if (!SHA256_RE.test(expectedTarballSha256)) {
|
|
throw new Error("Expected tarball SHA-256 must be 64 lowercase hex characters.");
|
|
}
|
|
}
|
|
const expectedTarballSizeBytes =
|
|
params.expectedTarballSizeBytes === undefined
|
|
? undefined
|
|
: assertPositiveInteger(params.expectedTarballSizeBytes, "expected tarball size");
|
|
const expectedInventory =
|
|
params.expectedInventory === undefined
|
|
? undefined
|
|
: normalizeExpectedInventory(params.expectedInventory);
|
|
const artifactId = assertPositiveInteger(params.artifactId, "artifact ID");
|
|
const artifactSizeBytes = assertPositiveInteger(params.artifactSizeBytes, "artifact size");
|
|
const runId = assertPositiveInteger(
|
|
params.producerRunId ?? params.runId,
|
|
"producer workflow run ID",
|
|
);
|
|
const runAttempt = assertPositiveInteger(
|
|
params.producerRunAttempt ?? params.runAttempt,
|
|
"producer workflow run attempt",
|
|
);
|
|
const workflowSha = assertString(params.workflowSha, "workflow SHA");
|
|
if (!SHA_RE.test(workflowSha)) {
|
|
throw new Error(`Workflow SHA must be a full lowercase commit SHA: ${workflowSha}`);
|
|
}
|
|
const expectedArtifactDigest = assertString(params.artifactDigest, "artifact digest");
|
|
if (!ARTIFACT_DIGEST_RE.test(expectedArtifactDigest)) {
|
|
throw new Error(`Invalid Actions artifact digest: ${expectedArtifactDigest}`);
|
|
}
|
|
const metadata = parseBoundedJsonFile(params.artifactMetadataPath, "Actions artifact metadata");
|
|
const workflowRun = parseBoundedJsonFile(
|
|
params.workflowRunMetadataPath,
|
|
"Actions workflow run metadata",
|
|
);
|
|
const expectedBinding = {
|
|
artifactDigest: expectedArtifactDigest,
|
|
artifactId,
|
|
artifactName: normalized.artifactName,
|
|
artifactSizeBytes,
|
|
consumerRunAttempt: params.consumerRunAttempt,
|
|
producerJobName: params.producerJobName,
|
|
repository: params.repository,
|
|
runStatePolicy: params.runStatePolicy ?? "completed-success",
|
|
runAttempt,
|
|
runId,
|
|
workflowEvent: params.workflowEvent,
|
|
workflowHeadBranch: params.workflowHeadBranch,
|
|
workflowPath: params.workflowPath,
|
|
workflowSha,
|
|
};
|
|
validateActionsArtifactBinding({
|
|
artifactMetadata: metadata,
|
|
expected: expectedBinding,
|
|
workflowRun,
|
|
});
|
|
if (expectedBinding.runStatePolicy === "same-run-producer-success") {
|
|
const workflowJobs = parseBoundedJsonFile(
|
|
params.workflowJobsMetadataPath,
|
|
"Actions workflow jobs metadata",
|
|
);
|
|
validateActionsArtifactProducerJob({ expected: expectedBinding, workflowJobs });
|
|
}
|
|
|
|
const zipBytes = readBoundedRegularFile(params.artifactZipPath, {
|
|
label: "Actions artifact ZIP",
|
|
maxBytes: MAX_ARCHIVE_BYTES,
|
|
});
|
|
if (zipBytes.length !== artifactSizeBytes) {
|
|
throw new Error("Actions artifact ZIP size does not match the immutable publish tuple.");
|
|
}
|
|
const actualArtifactDigest = sha256Digest(zipBytes);
|
|
if (actualArtifactDigest !== expectedArtifactDigest) {
|
|
throw new Error(
|
|
`Actions artifact ZIP digest ${actualArtifactDigest} does not match ${expectedArtifactDigest}.`,
|
|
);
|
|
}
|
|
const files = inspectPluginPublicationArtifactZip(zipBytes);
|
|
const manifestBytes = files.get(MANIFEST_FILENAME);
|
|
const tarballNames = [...files.keys()].filter((name) => name.endsWith(".tgz"));
|
|
if (!manifestBytes || tarballNames.length !== 1) {
|
|
throw new Error(
|
|
`Plugin publication artifact must contain ${MANIFEST_FILENAME} and exactly one .tgz.`,
|
|
);
|
|
}
|
|
if (manifestBytes.length === 0 || manifestBytes.length > MAX_MANIFEST_BYTES) {
|
|
throw new Error(`Plugin publication manifest size is outside the allowed range.`);
|
|
}
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Plugin publication manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
const manifestSourcePackageJsonSha256 = manifest?.package?.sourcePackageJsonSha256;
|
|
if (
|
|
typeof manifestSourcePackageJsonSha256 !== "string" ||
|
|
!SHA256_RE.test(manifestSourcePackageJsonSha256)
|
|
) {
|
|
throw new Error("Plugin publication manifest source package.json SHA-256 is invalid.");
|
|
}
|
|
if (
|
|
normalized.sourcePackageJsonSha256 !== undefined &&
|
|
normalized.sourcePackageJsonSha256 !== manifestSourcePackageJsonSha256
|
|
) {
|
|
throw new Error(
|
|
"Plugin publication source package.json SHA-256 does not match the approved target source.",
|
|
);
|
|
}
|
|
const tarballName = tarballNames[0];
|
|
const tarballBytes = files.get(tarballName);
|
|
const inspection = inspectPackageTarballBytes(
|
|
tarballBytes,
|
|
tarInspectionOptionsForRoute(normalized.route),
|
|
);
|
|
if (
|
|
expectedTarballSizeBytes !== undefined &&
|
|
inspection.tarballSizeBytes !== expectedTarballSizeBytes
|
|
) {
|
|
throw new Error("Plugin tarball size does not match the approved publication tuple.");
|
|
}
|
|
if (expectedTarballSha256 !== undefined && inspection.tarballSha256 !== expectedTarballSha256) {
|
|
throw new Error("Plugin tarball SHA-256 does not match the approved publication tuple.");
|
|
}
|
|
if (
|
|
expectedInventory !== undefined &&
|
|
JSON.stringify(inspection.inventory) !== JSON.stringify(expectedInventory)
|
|
) {
|
|
throw new Error("Plugin tarball inventory does not match the approved publication tuple.");
|
|
}
|
|
const expectedManifest = buildManifest(
|
|
{
|
|
...normalized,
|
|
sourcePackageJsonSha256: manifestSourcePackageJsonSha256,
|
|
},
|
|
tarballName,
|
|
tarballBytes,
|
|
inspection,
|
|
);
|
|
const expectedManifestBytes = Buffer.from(canonicalManifestText(expectedManifest), "utf8");
|
|
if (!manifestBytes.equals(expectedManifestBytes)) {
|
|
throw new Error(
|
|
"Plugin publication manifest does not canonically bind the approved package and tarball.",
|
|
);
|
|
}
|
|
|
|
const outputDir = resolve(params.outputDir);
|
|
createFreshOutputDirectory(outputDir, "Plugin publication output directory");
|
|
const outputPath = join(outputDir, tarballName);
|
|
writeFileSync(outputPath, tarballBytes, { flag: "wx", mode: 0o600 });
|
|
if (!statSync(outputPath).isFile()) {
|
|
throw new Error(`Verified plugin tarball was not written: ${outputPath}`);
|
|
}
|
|
return {
|
|
artifactDigest: expectedArtifactDigest,
|
|
artifactId,
|
|
artifactName: normalized.artifactName,
|
|
artifactSizeBytes,
|
|
artifactZipSha256: actualArtifactDigest.slice("sha256:".length),
|
|
manifest: expectedManifest,
|
|
npmIntegrity: expectedManifest.artifact.npmIntegrity,
|
|
npmShasum: expectedManifest.artifact.npmShasum,
|
|
packageJsonSha256: expectedManifest.package.packageJsonSha256,
|
|
pluginManifestSha256: expectedManifest.package.pluginManifestSha256,
|
|
producerRunAttempt: runAttempt,
|
|
producerRunId: runId,
|
|
sourcePackageJsonSha256: expectedManifest.package.sourcePackageJsonSha256,
|
|
tarballInventory: inspection.inventory,
|
|
tarballName,
|
|
tarballPath: outputPath,
|
|
tarballSizeBytes: inspection.tarballSizeBytes,
|
|
tarballSha256: inspection.tarballSha256,
|
|
};
|
|
}
|
|
|
|
function parseCliArgs(argv) {
|
|
const [command, ...rest] = argv;
|
|
if (command !== "create" && command !== "verify") {
|
|
throw new Error(
|
|
"Usage: plugin-publication-artifact.mjs <create|verify> --artifact-name <name> ...",
|
|
);
|
|
}
|
|
const values = {};
|
|
for (let index = 0; index < rest.length; index += 2) {
|
|
const key = rest[index];
|
|
const value = rest[index + 1];
|
|
if (!key?.startsWith("--") || value === undefined || value.startsWith("--")) {
|
|
throw new Error(`Invalid ${command} argument near ${key ?? "<missing>"}.`);
|
|
}
|
|
const name = key.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
|
|
if (values[name] !== undefined) {
|
|
throw new Error(`Duplicate ${command} option: ${key}`);
|
|
}
|
|
values[name] = value;
|
|
}
|
|
return { command, values };
|
|
}
|
|
|
|
function commonCliParams(values) {
|
|
return {
|
|
artifactName: values.artifactName,
|
|
bootstrapMode: values.bootstrapMode,
|
|
manualOverrideReason: values.manualOverrideReason,
|
|
packageDir: values.packageDir,
|
|
packageName: values.packageName,
|
|
publishTag: values.publishTag,
|
|
requiresManualOverride:
|
|
values.requiresManualOverride === undefined
|
|
? false
|
|
: assertBooleanString(values.requiresManualOverride, "requires-manual-override"),
|
|
route: values.route,
|
|
publicationReason: values.publicationReason,
|
|
publisherPolicy:
|
|
values.publisherPolicySchema === undefined &&
|
|
values.publisherPolicyId === undefined &&
|
|
values.publisherPolicySha256 === undefined
|
|
? undefined
|
|
: {
|
|
policyId: values.publisherPolicyId,
|
|
schema: values.publisherPolicySchema,
|
|
sha256: values.publisherPolicySha256,
|
|
},
|
|
sourcePackageJsonSha256: values.sourcePackageJsonSha256,
|
|
targetSha: values.targetSha,
|
|
version: values.packageVersion,
|
|
};
|
|
}
|
|
|
|
function appendGithubOutput(path, values) {
|
|
const lines = Object.entries(values).map(([name, value]) => `${name}=${String(value)}`);
|
|
writeFileSync(path, `${lines.join("\n")}\n`, { flag: "a" });
|
|
}
|
|
|
|
export function main(argv = process.argv.slice(2)) {
|
|
const { command, values } = parseCliArgs(argv);
|
|
const common = commonCliParams(values);
|
|
if (command === "create") {
|
|
const result = createPluginPublicationArtifact({
|
|
...common,
|
|
artifactDir: values.artifactDir,
|
|
});
|
|
console.log(`Created canonical plugin publication manifest: ${result.manifestPath}`);
|
|
console.log(`Prepared plugin tarball: ${result.tarballPath}`);
|
|
return;
|
|
}
|
|
const result = verifyPluginPublicationArtifact({
|
|
...common,
|
|
artifactDigest: values.artifactDigest,
|
|
artifactId: Number(values.artifactId),
|
|
artifactMetadataPath: values.artifactMetadata,
|
|
artifactSizeBytes: Number(values.artifactSizeBytes),
|
|
artifactZipPath: values.artifactZip,
|
|
expectedTarballSizeBytes:
|
|
values.expectedTarballSizeBytes === undefined
|
|
? undefined
|
|
: Number(values.expectedTarballSizeBytes),
|
|
expectedTarballSha256: values.expectedTarballSha256,
|
|
outputDir: values.outputDir,
|
|
consumerRunAttempt:
|
|
values.consumerRunAttempt === undefined ? undefined : Number(values.consumerRunAttempt),
|
|
producerJobName: values.producerJobName,
|
|
producerRunAttempt: Number(values.producerRunAttempt),
|
|
producerRunId: Number(values.producerRunId),
|
|
repository: values.repository,
|
|
workflowEvent: values.workflowEvent,
|
|
workflowHeadBranch: values.workflowHeadBranch,
|
|
workflowPath: values.workflowPath,
|
|
workflowJobsMetadataPath: values.workflowJobsMetadata,
|
|
workflowRunMetadataPath: values.workflowRunMetadata,
|
|
runStatePolicy: values.runStatePolicy,
|
|
workflowSha: values.workflowSha,
|
|
});
|
|
if (values.githubOutput) {
|
|
appendGithubOutput(values.githubOutput, {
|
|
artifact_digest: result.artifactDigest,
|
|
artifact_id: result.artifactId,
|
|
artifact_name: result.artifactName,
|
|
artifact_size_bytes: result.artifactSizeBytes,
|
|
artifact_zip_sha256: result.artifactZipSha256,
|
|
bootstrap_mode: result.manifest.publication.bootstrapMode ?? "",
|
|
manual_override_reason: result.manifest.publication.manualOverrideReason ?? "",
|
|
package_name: result.manifest.package.name,
|
|
package_json_sha256: result.manifest.package.packageJsonSha256,
|
|
package_version: result.manifest.package.version,
|
|
publish_route: result.manifest.publication.route,
|
|
publish_tag: result.manifest.publication.tag,
|
|
producer_run_attempt: result.producerRunAttempt,
|
|
producer_run_id: result.producerRunId,
|
|
requires_manual_override: result.manifest.publication.requiresManualOverride,
|
|
source_package_json_sha256: result.manifest.package.sourcePackageJsonSha256,
|
|
tarball_path: result.tarballPath,
|
|
tarball_sha256: result.tarballSha256,
|
|
});
|
|
}
|
|
console.log(
|
|
`Verified ${result.manifest.package.name}@${result.manifest.package.version} plugin artifact ${result.artifactId} (${result.artifactDigest}).`,
|
|
);
|
|
}
|
|
|
|
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;
|
|
if (entrypoint === import.meta.url) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|