mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 07:46:07 +00:00
* fix(release): harden ClawHub bootstrap * fix(release): centralize publication artifact verification * fix(release): harden publication artifact verification * fix(release): lock ClawHub bootstrap toolchain * test(release): assert locked ClawHub binary * fix(release): pack with trusted ClawHub harness * fix(release): bound beta verifier artifact reads * fix(release): bind target and tooling identities * fix(release): stabilize signed package ordering * fix(release): bind ClawHub pretag proof * fix(release): bind ClawHub OIDC readback * fix(release): bind ClawHub OIDC readback
995 lines
35 KiB
JavaScript
995 lines
35 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
import { basename } from "node:path";
|
|
import { inflateRawSync } from "node:zlib";
|
|
|
|
export const ACTIONS_ARTIFACT_API_VERSION = "2026-03-10";
|
|
export const DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES = 256 * 1024 * 1024;
|
|
export const DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES = 512 * 1024 * 1024;
|
|
|
|
const DEFAULT_MAX_JSON_BYTES = 2 * 1024 * 1024;
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const ARTIFACT_DIGEST_RE = /^sha256:[0-9a-f]{64}$/u;
|
|
const ARTIFACT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/u;
|
|
const REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
|
|
const SUPPORTED_ZIP_FLAGS = 0x0808;
|
|
const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008;
|
|
const ZIP_UTF8_FLAG = 0x0800;
|
|
const ZIP_LOCAL_HEADER_SIGNATURE = 0x04034b50;
|
|
const ZIP_CENTRAL_HEADER_SIGNATURE = 0x02014b50;
|
|
const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
|
|
const ZIP_EOCD_SIGNATURE = 0x06054b50;
|
|
const ZIP_EOCD_BYTES = 22;
|
|
const ZIP_DATA_DESCRIPTOR_BYTES = 16;
|
|
const ZIP_MAX_COMMENT_BYTES = 65_535;
|
|
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
|
|
function assertPositiveInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error(`${label} must be a positive safe integer.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertTrimmedString(value, label) {
|
|
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
|
throw new Error(`${label} must be a non-empty trimmed string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function hasControlCharacters(value) {
|
|
for (const character of value) {
|
|
const codePoint = character.codePointAt(0);
|
|
if (codePoint <= 0x1f || codePoint === 0x7f) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function boundedLimit(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 asBuffer(bytes, label) {
|
|
if (!(bytes instanceof Uint8Array)) {
|
|
throw new Error(`${label} must be a Uint8Array.`);
|
|
}
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
}
|
|
|
|
function decodeUtf8Exact(bytes, label) {
|
|
let value;
|
|
try {
|
|
value = UTF8_DECODER.decode(bytes);
|
|
} catch {
|
|
throw new Error(`${label} is not valid UTF-8.`);
|
|
}
|
|
if (!Buffer.from(value, "utf8").equals(bytes)) {
|
|
throw new Error(`${label} is not canonically encoded UTF-8.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertSafeArchivePath(value, label) {
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length === 0 ||
|
|
value.startsWith("/") ||
|
|
value.endsWith("/") ||
|
|
value.includes("\\") ||
|
|
value.includes("\0") ||
|
|
value.normalize("NFC") !== value ||
|
|
hasControlCharacters(value)
|
|
) {
|
|
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
|
|
}
|
|
const parts = value.split("/");
|
|
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) {
|
|
throw new Error(`Unsafe ${label}: ${JSON.stringify(value)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertArtifactName(value) {
|
|
const name = assertTrimmedString(value, "Actions artifact name");
|
|
if (!ARTIFACT_NAME_RE.test(name)) {
|
|
throw new Error(`Invalid Actions artifact name: ${name}`);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function assertArtifactDigest(value) {
|
|
const digest = assertTrimmedString(value, "Actions artifact digest");
|
|
if (!ARTIFACT_DIGEST_RE.test(digest)) {
|
|
throw new Error("Actions artifact digest must be sha256:<64 lowercase hex>.");
|
|
}
|
|
return digest;
|
|
}
|
|
|
|
function assertCommitSha(value, label) {
|
|
const sha = assertTrimmedString(value, label);
|
|
if (!COMMIT_SHA_RE.test(sha)) {
|
|
throw new Error(`${label} must be a full lowercase commit SHA.`);
|
|
}
|
|
return sha;
|
|
}
|
|
|
|
function assertWorkflowPath(value) {
|
|
const workflowPath = assertTrimmedString(value, "workflow path");
|
|
if (
|
|
!/^\.github\/workflows\/[A-Za-z0-9][A-Za-z0-9_.-]*\.ya?ml$/u.test(workflowPath) ||
|
|
hasControlCharacters(workflowPath)
|
|
) {
|
|
throw new Error(`Invalid workflow path: ${workflowPath}`);
|
|
}
|
|
return workflowPath;
|
|
}
|
|
|
|
function assertRepository(value) {
|
|
const repository = assertTrimmedString(value, "GitHub repository");
|
|
if (!REPOSITORY_RE.test(repository)) {
|
|
throw new Error("GitHub repository must be owner/name.");
|
|
}
|
|
return repository;
|
|
}
|
|
|
|
export function sha256Digest(bytes) {
|
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
}
|
|
|
|
function compareCodeUnits(left, right) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
export function describeActionsArtifactFiles(files) {
|
|
if (!(files instanceof Map)) {
|
|
throw new Error("Actions artifact files must be a Map.");
|
|
}
|
|
return [...files.entries()]
|
|
.map(([path, bytes]) => {
|
|
const safePath = assertSafeArchivePath(path, "Actions artifact file path");
|
|
const content = asBuffer(bytes, `Actions artifact file ${safePath}`);
|
|
return {
|
|
path: safePath,
|
|
sha256: sha256Digest(content).slice("sha256:".length),
|
|
sizeBytes: content.byteLength,
|
|
};
|
|
})
|
|
.toSorted((left, right) => compareCodeUnits(left.path, right.path));
|
|
}
|
|
|
|
export function readBoundedRegularFile(path, params) {
|
|
if (!Number.isSafeInteger(params.maxBytes) || params.maxBytes <= 0) {
|
|
throw new Error(`${params.label} byte limit must be a positive safe integer.`);
|
|
}
|
|
let descriptor;
|
|
try {
|
|
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
const before = fstatSync(descriptor);
|
|
if (!before.isFile()) {
|
|
throw new Error(`${params.label} must be a regular file.`);
|
|
}
|
|
if (before.size === 0 || before.size > params.maxBytes) {
|
|
throw new Error(`${params.label} size is outside the allowed range: ${before.size}.`);
|
|
}
|
|
|
|
const bytes = Buffer.allocUnsafe(before.size);
|
|
let offset = 0;
|
|
while (offset < bytes.length) {
|
|
const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
if (bytesRead === 0) {
|
|
throw new Error(`${params.label} changed while it was being read.`);
|
|
}
|
|
offset += bytesRead;
|
|
}
|
|
const extra = Buffer.allocUnsafe(1);
|
|
const extraBytes = readSync(descriptor, extra, 0, 1, before.size);
|
|
const after = fstatSync(descriptor);
|
|
if (extraBytes !== 0 || after.size !== before.size) {
|
|
throw new Error(`${params.label} changed while it was being read.`);
|
|
}
|
|
return bytes;
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && error.code === "ELOOP") {
|
|
throw new Error(`${params.label} must be a regular file.`, { cause: error });
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (descriptor !== undefined) {
|
|
closeSync(descriptor);
|
|
}
|
|
}
|
|
}
|
|
|
|
function findEndOfCentralDirectory(bytes) {
|
|
const minOffset = Math.max(0, bytes.length - ZIP_EOCD_BYTES - ZIP_MAX_COMMENT_BYTES);
|
|
for (let offset = bytes.length - ZIP_EOCD_BYTES; offset >= minOffset; offset -= 1) {
|
|
if (bytes.readUInt32LE(offset) !== ZIP_EOCD_SIGNATURE) {
|
|
continue;
|
|
}
|
|
const commentLength = bytes.readUInt16LE(offset + 20);
|
|
if (offset + ZIP_EOCD_BYTES + commentLength === bytes.length) {
|
|
if (commentLength !== 0) {
|
|
throw new Error("Actions artifact ZIP comments are not supported.");
|
|
}
|
|
return offset;
|
|
}
|
|
}
|
|
throw new Error(
|
|
"Actions artifact ZIP is missing an exact terminal end-of-central-directory record.",
|
|
);
|
|
}
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function normalizeArchivePolicy(policy) {
|
|
if (!policy || typeof policy !== "object") {
|
|
throw new Error("Actions artifact ZIP policy is required.");
|
|
}
|
|
const maxArchiveBytes = boundedLimit(
|
|
policy.maxArchiveBytes,
|
|
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
|
|
"Actions artifact ZIP byte limit",
|
|
);
|
|
const maxExpandedBytes = boundedLimit(
|
|
policy.maxExpandedBytes,
|
|
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
|
|
"Actions artifact ZIP expanded-byte limit",
|
|
);
|
|
let expectedEntries;
|
|
if (policy.expectedEntries !== undefined) {
|
|
if (
|
|
!Array.isArray(policy.expectedEntries) ||
|
|
policy.expectedEntries.length === 0 ||
|
|
policy.expectedEntries.length > 1024
|
|
) {
|
|
throw new Error("Actions artifact ZIP expected inventory is invalid.");
|
|
}
|
|
expectedEntries = policy.expectedEntries.map((name) =>
|
|
assertSafeArchivePath(name, "expected ZIP entry path"),
|
|
);
|
|
if (new Set(expectedEntries).size !== expectedEntries.length) {
|
|
throw new Error("Actions artifact ZIP expected inventory contains duplicates.");
|
|
}
|
|
}
|
|
const minEntries = expectedEntries?.length ?? policy.minEntries;
|
|
const maxEntries = expectedEntries?.length ?? policy.maxEntries;
|
|
if (
|
|
!Number.isSafeInteger(minEntries) ||
|
|
!Number.isSafeInteger(maxEntries) ||
|
|
minEntries <= 0 ||
|
|
maxEntries < minEntries ||
|
|
maxEntries > 1024
|
|
) {
|
|
throw new Error("Actions artifact ZIP entry-count policy is invalid.");
|
|
}
|
|
if (!expectedEntries && typeof policy.allowPath !== "function") {
|
|
throw new Error("Actions artifact ZIP path policy is required.");
|
|
}
|
|
if (typeof policy.maxEntryBytes !== "function") {
|
|
throw new Error("Actions artifact ZIP per-entry expanded-byte policy is required.");
|
|
}
|
|
if (
|
|
policy.maxCompressedEntryBytes !== undefined &&
|
|
typeof policy.maxCompressedEntryBytes !== "function"
|
|
) {
|
|
throw new Error("Actions artifact ZIP per-entry compressed-byte policy is invalid.");
|
|
}
|
|
return {
|
|
expectedEntries,
|
|
expectedEntrySet: expectedEntries ? new Set(expectedEntries) : undefined,
|
|
maxArchiveBytes,
|
|
maxEntries,
|
|
maxExpandedBytes,
|
|
minEntries,
|
|
rejectCaseFoldAliases: policy.rejectCaseFoldAliases !== false,
|
|
allowPath: expectedEntries
|
|
? (name) => new Set(expectedEntries).has(name)
|
|
: (name) => policy.allowPath(name),
|
|
maxCompressedEntryBytes: (name) =>
|
|
boundedLimit(
|
|
policy.maxCompressedEntryBytes?.(name),
|
|
maxArchiveBytes,
|
|
`Actions artifact ZIP compressed entry limit for ${name}`,
|
|
),
|
|
maxEntryBytes: (name) =>
|
|
boundedLimit(
|
|
policy.maxEntryBytes(name),
|
|
maxExpandedBytes,
|
|
`Actions artifact ZIP expanded entry limit for ${name}`,
|
|
),
|
|
};
|
|
}
|
|
|
|
function inspectCentralDirectory(bytes, eocd, policy) {
|
|
const disk = bytes.readUInt16LE(eocd + 4);
|
|
const centralDisk = bytes.readUInt16LE(eocd + 6);
|
|
const diskEntries = bytes.readUInt16LE(eocd + 8);
|
|
const totalEntries = bytes.readUInt16LE(eocd + 10);
|
|
const centralSize = bytes.readUInt32LE(eocd + 12);
|
|
const centralOffset = bytes.readUInt32LE(eocd + 16);
|
|
if (
|
|
disk !== 0 ||
|
|
centralDisk !== 0 ||
|
|
diskEntries !== totalEntries ||
|
|
totalEntries === 0xffff ||
|
|
centralSize === 0xffffffff ||
|
|
centralOffset === 0xffffffff
|
|
) {
|
|
throw new Error("Multi-disk and ZIP64 Actions artifacts are not supported.");
|
|
}
|
|
if (
|
|
totalEntries < policy.minEntries ||
|
|
totalEntries > policy.maxEntries ||
|
|
centralOffset + centralSize !== eocd
|
|
) {
|
|
throw new Error(
|
|
`Actions artifact ZIP must contain between ${policy.minEntries} and ${policy.maxEntries} exact files with a contiguous central directory.`,
|
|
);
|
|
}
|
|
|
|
const records = [];
|
|
const names = new Set();
|
|
const aliases = new Set();
|
|
let offset = centralOffset;
|
|
let declaredExpandedBytes = 0;
|
|
for (let index = 0; index < totalEntries; index += 1) {
|
|
if (offset + 46 > eocd || bytes.readUInt32LE(offset) !== ZIP_CENTRAL_HEADER_SIGNATURE) {
|
|
throw new Error("Invalid Actions artifact ZIP central-directory entry.");
|
|
}
|
|
const versionNeeded = bytes.readUInt16LE(offset + 6);
|
|
const flags = bytes.readUInt16LE(offset + 8);
|
|
const compression = bytes.readUInt16LE(offset + 10);
|
|
const expectedCrc = bytes.readUInt32LE(offset + 16);
|
|
const compressedSize = bytes.readUInt32LE(offset + 20);
|
|
const expandedSize = bytes.readUInt32LE(offset + 24);
|
|
const nameLength = bytes.readUInt16LE(offset + 28);
|
|
const extraLength = bytes.readUInt16LE(offset + 30);
|
|
const commentLength = bytes.readUInt16LE(offset + 32);
|
|
const startDisk = bytes.readUInt16LE(offset + 34);
|
|
const externalAttributes = bytes.readUInt32LE(offset + 38);
|
|
const localOffset = bytes.readUInt32LE(offset + 42);
|
|
const nameStart = offset + 46;
|
|
const nameEnd = nameStart + nameLength;
|
|
const nextOffset = nameEnd + extraLength + commentLength;
|
|
if (nextOffset > eocd) {
|
|
throw new Error("Actions artifact ZIP central directory exceeds its declared boundary.");
|
|
}
|
|
if (
|
|
versionNeeded > 20 ||
|
|
startDisk !== 0 ||
|
|
nameLength === 0 ||
|
|
extraLength !== 0 ||
|
|
commentLength !== 0
|
|
) {
|
|
throw new Error("Actions artifact ZIP central-directory encoding is not canonical.");
|
|
}
|
|
if ((flags & ~SUPPORTED_ZIP_FLAGS) !== 0 || (flags & 0x0001) !== 0) {
|
|
throw new Error(`Unsupported Actions artifact ZIP flags 0x${flags.toString(16)}.`);
|
|
}
|
|
if (compression !== 0 && compression !== 8) {
|
|
throw new Error(`Unsupported Actions artifact ZIP compression method ${compression}.`);
|
|
}
|
|
if (
|
|
compressedSize === 0xffffffff ||
|
|
expandedSize === 0xffffffff ||
|
|
localOffset === 0xffffffff
|
|
) {
|
|
throw new Error("ZIP64 Actions artifact entries are not supported.");
|
|
}
|
|
|
|
const rawName = Buffer.from(bytes.subarray(nameStart, nameEnd));
|
|
if ((flags & ZIP_UTF8_FLAG) === 0 && rawName.some((byte) => byte >= 0x80)) {
|
|
throw new Error(
|
|
"Actions artifact ZIP non-ASCII entry names must set the UTF-8 language flag.",
|
|
);
|
|
}
|
|
const name = assertSafeArchivePath(
|
|
decodeUtf8Exact(rawName, "Actions artifact ZIP entry path"),
|
|
"ZIP entry path",
|
|
);
|
|
const alias = name.toLocaleLowerCase("en-US");
|
|
if (
|
|
names.has(name) ||
|
|
(policy.rejectCaseFoldAliases && aliases.has(alias)) ||
|
|
!policy.allowPath(name)
|
|
) {
|
|
throw new Error(`Unexpected, duplicate, or aliased Actions artifact ZIP entry: ${name}`);
|
|
}
|
|
names.add(name);
|
|
aliases.add(alias);
|
|
|
|
const unixMode = (externalAttributes >>> 16) & 0xffff;
|
|
const unixType = unixMode & 0o170000;
|
|
const dosAttributes = externalAttributes & 0xffff;
|
|
if ((unixType !== 0 && unixType !== 0o100000) || (dosAttributes & 0x10) !== 0) {
|
|
throw new Error(`Actions artifact ZIP entry is not a regular file: ${name}`);
|
|
}
|
|
const maxCompressedBytes = policy.maxCompressedEntryBytes(name);
|
|
const maxEntryBytes = policy.maxEntryBytes(name);
|
|
if (
|
|
compressedSize > maxCompressedBytes ||
|
|
expandedSize > maxEntryBytes ||
|
|
declaredExpandedBytes + expandedSize > policy.maxExpandedBytes
|
|
) {
|
|
throw new Error(`Actions artifact ZIP entry is too large: ${name}`);
|
|
}
|
|
if (compression === 0 && compressedSize !== expandedSize) {
|
|
throw new Error(`Stored Actions artifact ZIP entry size mismatch for ${name}.`);
|
|
}
|
|
declaredExpandedBytes += expandedSize;
|
|
records.push({
|
|
compressedSize,
|
|
compression,
|
|
expandedSize,
|
|
expectedCrc,
|
|
flags,
|
|
localOffset,
|
|
maxEntryBytes,
|
|
name,
|
|
rawName,
|
|
versionNeeded,
|
|
});
|
|
offset = nextOffset;
|
|
}
|
|
if (offset !== eocd) {
|
|
throw new Error("Actions artifact ZIP central-directory size mismatch.");
|
|
}
|
|
return { centralOffset, records };
|
|
}
|
|
|
|
function expandZipEntry(compressed, record, remainingExpandedBytes) {
|
|
const outputLimit = Math.min(record.maxEntryBytes, remainingExpandedBytes);
|
|
try {
|
|
if (record.compression === 0) {
|
|
return compressed;
|
|
}
|
|
const result = inflateRawSync(compressed, {
|
|
info: true,
|
|
maxOutputLength: Math.max(1, outputLimit),
|
|
});
|
|
if (result.engine.bytesWritten !== compressed.length) {
|
|
throw new Error("deflate stream does not consume its full compressed member");
|
|
}
|
|
return result.buffer;
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Actions artifact ZIP entry expansion exceeds its allowed range: ${record.name}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
function inspectLocalRecords(bytes, centralOffset, records, policy) {
|
|
const files = new Map();
|
|
const ordered = records.toSorted((left, right) => left.localOffset - right.localOffset);
|
|
let expectedOffset = 0;
|
|
let totalExpandedBytes = 0;
|
|
|
|
for (const [index, record] of ordered.entries()) {
|
|
if (
|
|
record.localOffset !== expectedOffset ||
|
|
record.localOffset + 30 > centralOffset ||
|
|
bytes.readUInt32LE(record.localOffset) !== ZIP_LOCAL_HEADER_SIGNATURE
|
|
) {
|
|
throw new Error(
|
|
`Non-contiguous or invalid Actions artifact ZIP local record: ${record.name}`,
|
|
);
|
|
}
|
|
const localVersionNeeded = bytes.readUInt16LE(record.localOffset + 4);
|
|
const localFlags = bytes.readUInt16LE(record.localOffset + 6);
|
|
const localCompression = bytes.readUInt16LE(record.localOffset + 8);
|
|
const localCrc = bytes.readUInt32LE(record.localOffset + 14);
|
|
const localCompressedSize = bytes.readUInt32LE(record.localOffset + 18);
|
|
const localExpandedSize = bytes.readUInt32LE(record.localOffset + 22);
|
|
const localNameLength = bytes.readUInt16LE(record.localOffset + 26);
|
|
const localExtraLength = bytes.readUInt16LE(record.localOffset + 28);
|
|
const localNameStart = record.localOffset + 30;
|
|
const localNameEnd = localNameStart + localNameLength;
|
|
if (
|
|
localNameEnd > centralOffset ||
|
|
localVersionNeeded !== record.versionNeeded ||
|
|
localFlags !== record.flags ||
|
|
localCompression !== record.compression ||
|
|
localExtraLength !== 0
|
|
) {
|
|
throw new Error(`Actions artifact ZIP local header mismatch for ${record.name}.`);
|
|
}
|
|
const rawLocalName = bytes.subarray(localNameStart, localNameEnd);
|
|
if (!record.rawName.equals(rawLocalName)) {
|
|
throw new Error(`Actions artifact ZIP local and central names differ for ${record.name}.`);
|
|
}
|
|
decodeUtf8Exact(rawLocalName, `Actions artifact ZIP local entry path for ${record.name}`);
|
|
|
|
const dataStart = localNameEnd;
|
|
const dataEnd = dataStart + record.compressedSize;
|
|
if (dataEnd > centralOffset) {
|
|
throw new Error(`Actions artifact ZIP data exceeds its boundary: ${record.name}`);
|
|
}
|
|
|
|
let recordEnd = dataEnd;
|
|
if ((record.flags & ZIP_DATA_DESCRIPTOR_FLAG) !== 0) {
|
|
if (localCrc !== 0 || localCompressedSize !== 0 || localExpandedSize !== 0) {
|
|
throw new Error(
|
|
`Actions artifact ZIP descriptor-backed local sizes must be zero: ${record.name}`,
|
|
);
|
|
}
|
|
if (
|
|
dataEnd + ZIP_DATA_DESCRIPTOR_BYTES > centralOffset ||
|
|
bytes.readUInt32LE(dataEnd) !== ZIP_DATA_DESCRIPTOR_SIGNATURE ||
|
|
bytes.readUInt32LE(dataEnd + 4) !== record.expectedCrc ||
|
|
bytes.readUInt32LE(dataEnd + 8) !== record.compressedSize ||
|
|
bytes.readUInt32LE(dataEnd + 12) !== record.expandedSize
|
|
) {
|
|
throw new Error(`Invalid Actions artifact ZIP data descriptor for ${record.name}.`);
|
|
}
|
|
recordEnd += ZIP_DATA_DESCRIPTOR_BYTES;
|
|
} else if (
|
|
localCrc !== record.expectedCrc ||
|
|
localCompressedSize !== record.compressedSize ||
|
|
localExpandedSize !== record.expandedSize
|
|
) {
|
|
throw new Error(`Actions artifact ZIP local sizes or CRC differ for ${record.name}.`);
|
|
}
|
|
|
|
const nextOffset = ordered[index + 1]?.localOffset ?? centralOffset;
|
|
if (recordEnd !== nextOffset) {
|
|
throw new Error(`Actions artifact ZIP contains a gap or overlap after ${record.name}.`);
|
|
}
|
|
|
|
const compressed = bytes.subarray(dataStart, dataEnd);
|
|
const expanded = expandZipEntry(
|
|
compressed,
|
|
record,
|
|
policy.maxExpandedBytes - totalExpandedBytes,
|
|
);
|
|
if (
|
|
expanded.length !== record.expandedSize ||
|
|
totalExpandedBytes + expanded.length > policy.maxExpandedBytes ||
|
|
crc32(expanded) !== record.expectedCrc
|
|
) {
|
|
throw new Error(`Actions artifact ZIP checksum mismatch for ${record.name}.`);
|
|
}
|
|
totalExpandedBytes += expanded.length;
|
|
files.set(record.name, Buffer.from(expanded));
|
|
expectedOffset = recordEnd;
|
|
}
|
|
if (expectedOffset !== centralOffset) {
|
|
throw new Error("Actions artifact ZIP local records do not end at the central directory.");
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export function inspectActionsArtifactZipWithPolicy(inputBytes, inputPolicy) {
|
|
const bytes = asBuffer(inputBytes, "Actions artifact ZIP");
|
|
const policy = normalizeArchivePolicy(inputPolicy);
|
|
if (bytes.length === 0 || bytes.length > policy.maxArchiveBytes) {
|
|
throw new Error(`Actions artifact ZIP size is outside the allowed range: ${bytes.length}.`);
|
|
}
|
|
const eocd = findEndOfCentralDirectory(bytes);
|
|
const { centralOffset, records } = inspectCentralDirectory(bytes, eocd, policy);
|
|
const files = inspectLocalRecords(bytes, centralOffset, records, policy);
|
|
if (policy.expectedEntries) {
|
|
const actual = [...files.keys()].toSorted(compareCodeUnits);
|
|
const expected = [...policy.expectedEntries].toSorted(compareCodeUnits);
|
|
if (
|
|
actual.length !== expected.length ||
|
|
actual.some((name, index) => name !== expected[index])
|
|
) {
|
|
throw new Error(
|
|
`Actions artifact ZIP inventory mismatch: expected ${expected.join(", ")}, found ${actual.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
export function inspectActionsArtifactZip(bytes, expectedEntries = 2, limits = {}) {
|
|
let expectedInventory;
|
|
let expectedCount;
|
|
if (Array.isArray(expectedEntries)) {
|
|
expectedInventory = expectedEntries;
|
|
expectedCount = expectedEntries.length;
|
|
} else {
|
|
expectedCount = assertPositiveInteger(expectedEntries, "Expected Actions artifact entry count");
|
|
}
|
|
const maxArchiveBytes = boundedLimit(
|
|
limits.maxArchiveBytes,
|
|
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
|
|
"Actions artifact ZIP byte limit",
|
|
);
|
|
const maxExpandedBytes = boundedLimit(
|
|
limits.maxExpandedBytes,
|
|
DEFAULT_MAX_ACTIONS_ARTIFACT_EXPANDED_BYTES,
|
|
"Actions artifact ZIP expanded-byte limit",
|
|
);
|
|
const maxEntryBytes = boundedLimit(
|
|
limits.maxEntryBytes,
|
|
maxExpandedBytes,
|
|
"Actions artifact ZIP expanded entry limit",
|
|
);
|
|
const maxCompressedEntryBytes = boundedLimit(
|
|
limits.maxCompressedEntryBytes,
|
|
maxArchiveBytes,
|
|
"Actions artifact ZIP compressed entry limit",
|
|
);
|
|
return inspectActionsArtifactZipWithPolicy(bytes, {
|
|
expectedEntries: expectedInventory,
|
|
minEntries: expectedCount,
|
|
maxEntries: expectedCount,
|
|
maxArchiveBytes,
|
|
maxExpandedBytes,
|
|
allowPath: expectedInventory ? undefined : (name) => basename(name) === name,
|
|
maxCompressedEntryBytes: () => maxCompressedEntryBytes,
|
|
maxEntryBytes: () => maxEntryBytes,
|
|
});
|
|
}
|
|
|
|
function requireExpectedBinding(params) {
|
|
const expected = params.expected;
|
|
if (!expected || typeof expected !== "object") {
|
|
throw new Error("Expected Actions artifact binding is required.");
|
|
}
|
|
const repository = assertRepository(expected.repository);
|
|
const artifactId = assertPositiveInteger(expected.artifactId, "Actions artifact ID");
|
|
const artifactName = assertArtifactName(expected.artifactName);
|
|
const artifactDigest = assertArtifactDigest(expected.artifactDigest);
|
|
const artifactSizeBytes = assertPositiveInteger(
|
|
expected.artifactSizeBytes,
|
|
"Actions artifact size",
|
|
);
|
|
const runId = assertPositiveInteger(expected.runId, "workflow run ID");
|
|
const runAttempt = assertPositiveInteger(expected.runAttempt, "workflow run attempt");
|
|
const workflowSha = assertCommitSha(expected.workflowSha, "workflow SHA");
|
|
const workflowPath = assertWorkflowPath(expected.workflowPath);
|
|
const workflowEvent = assertTrimmedString(expected.workflowEvent, "workflow event");
|
|
const workflowHeadBranch = assertTrimmedString(
|
|
expected.workflowHeadBranch,
|
|
"workflow head branch",
|
|
);
|
|
const runStatePolicy = assertTrimmedString(expected.runStatePolicy, "workflow run-state policy");
|
|
if (runStatePolicy !== "completed-success" && runStatePolicy !== "same-run-producer-success") {
|
|
throw new Error(`Unsupported workflow run-state policy: ${runStatePolicy}`);
|
|
}
|
|
const consumerRunAttempt =
|
|
runStatePolicy === "same-run-producer-success"
|
|
? assertPositiveInteger(expected.consumerRunAttempt, "consumer workflow run attempt")
|
|
: undefined;
|
|
const producerJobName =
|
|
runStatePolicy === "same-run-producer-success"
|
|
? assertTrimmedString(expected.producerJobName, "producer job name")
|
|
: undefined;
|
|
if (consumerRunAttempt !== undefined && runAttempt > consumerRunAttempt) {
|
|
throw new Error("Producer workflow run attempt must not be newer than the consumer attempt.");
|
|
}
|
|
return {
|
|
artifactDigest,
|
|
artifactId,
|
|
artifactName,
|
|
artifactSizeBytes,
|
|
consumerRunAttempt,
|
|
producerJobName,
|
|
repository,
|
|
runStatePolicy,
|
|
runAttempt,
|
|
runId,
|
|
workflowEvent,
|
|
workflowHeadBranch,
|
|
workflowPath,
|
|
workflowSha,
|
|
};
|
|
}
|
|
|
|
export function validateActionsArtifactBinding(params) {
|
|
const expected = requireExpectedBinding(params);
|
|
const artifact = params.artifactMetadata;
|
|
const run = params.workflowRun;
|
|
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
throw new Error("Actions artifact metadata must be an object.");
|
|
}
|
|
if (!run || typeof run !== "object" || Array.isArray(run)) {
|
|
throw new Error("Actions workflow run metadata must be an object.");
|
|
}
|
|
if (
|
|
artifact.id !== expected.artifactId ||
|
|
artifact.name !== expected.artifactName ||
|
|
artifact.size_in_bytes !== expected.artifactSizeBytes ||
|
|
artifact.expired !== false ||
|
|
artifact.digest !== expected.artifactDigest ||
|
|
artifact.workflow_run?.id !== expected.runId ||
|
|
artifact.workflow_run?.head_sha !== expected.workflowSha
|
|
) {
|
|
throw new Error("Actions artifact metadata does not match the immutable publication tuple.");
|
|
}
|
|
if (
|
|
run.id !== expected.runId ||
|
|
run.run_attempt !== expected.runAttempt ||
|
|
run.head_sha !== expected.workflowSha ||
|
|
run.head_branch !== expected.workflowHeadBranch ||
|
|
run.event !== expected.workflowEvent ||
|
|
run.path !== expected.workflowPath ||
|
|
run.repository?.full_name !== expected.repository ||
|
|
run.head_repository?.full_name !== expected.repository
|
|
) {
|
|
throw new Error("Actions workflow run does not match the immutable publication tuple.");
|
|
}
|
|
if (expected.runStatePolicy === "completed-success") {
|
|
if (run.status !== "completed" || run.conclusion !== "success") {
|
|
throw new Error("Actions workflow run does not match the immutable publication tuple.");
|
|
}
|
|
} else if (expected.runAttempt === expected.consumerRunAttempt) {
|
|
if (run.status !== "in_progress" || run.conclusion !== null) {
|
|
throw new Error("Current producer workflow attempt must still be in progress.");
|
|
}
|
|
} else if (
|
|
run.status !== "completed" ||
|
|
typeof run.conclusion !== "string" ||
|
|
run.conclusion.length === 0
|
|
) {
|
|
throw new Error("Prior producer workflow attempt must be completed.");
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
export function validateActionsArtifactProducerJob(params) {
|
|
const expected = requireExpectedBinding(params);
|
|
if (expected.runStatePolicy !== "same-run-producer-success") {
|
|
return expected;
|
|
}
|
|
const response = params.workflowJobs;
|
|
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
throw new Error("Actions workflow jobs response must be an object.");
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(response.total_count) ||
|
|
response.total_count < 0 ||
|
|
!Array.isArray(response.jobs) ||
|
|
response.total_count !== response.jobs.length
|
|
) {
|
|
throw new Error("Actions workflow jobs inventory is incomplete.");
|
|
}
|
|
const matches = response.jobs.filter((job) => job?.name === expected.producerJobName);
|
|
if (matches.length !== 1) {
|
|
throw new Error("Actions artifact producer job must be unique.");
|
|
}
|
|
const [producerJob] = matches;
|
|
if (
|
|
producerJob.run_id !== expected.runId ||
|
|
producerJob.run_attempt !== expected.runAttempt ||
|
|
producerJob.head_sha !== expected.workflowSha ||
|
|
producerJob.status !== "completed" ||
|
|
producerJob.conclusion !== "success"
|
|
) {
|
|
throw new Error("Actions artifact producer job did not complete successfully.");
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
async function readBoundedResponseBody(response, params) {
|
|
if (!response.ok || !response.body) {
|
|
await response.body?.cancel();
|
|
throw new Error(`${params.label} returned HTTP ${response.status}.`);
|
|
}
|
|
const contentLength = response.headers.get("content-length");
|
|
if (contentLength !== null) {
|
|
if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLength)) {
|
|
await response.body?.cancel();
|
|
throw new Error(`${params.label} returned an invalid Content-Length.`);
|
|
}
|
|
const declaredBytes = Number(contentLength);
|
|
if (
|
|
!Number.isSafeInteger(declaredBytes) ||
|
|
declaredBytes > params.maxBytes ||
|
|
(params.expectedBytes !== undefined && declaredBytes !== params.expectedBytes)
|
|
) {
|
|
await response.body?.cancel();
|
|
throw new Error(`${params.label} Content-Length is outside the approved range.`);
|
|
}
|
|
}
|
|
|
|
const chunks = [];
|
|
let totalBytes = 0;
|
|
const reader = response.body.getReader();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
totalBytes += value.byteLength;
|
|
if (
|
|
totalBytes > params.maxBytes ||
|
|
(params.expectedBytes !== undefined && totalBytes > params.expectedBytes)
|
|
) {
|
|
await reader.cancel();
|
|
throw new Error(`${params.label} exceeded its approved byte count.`);
|
|
}
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
} catch (error) {
|
|
await reader.cancel().catch(() => undefined);
|
|
throw error;
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
if (totalBytes === 0) {
|
|
throw new Error(`${params.label} returned an empty body.`);
|
|
}
|
|
if (params.expectedBytes !== undefined && totalBytes !== params.expectedBytes) {
|
|
throw new Error(`${params.label} size does not match metadata.`);
|
|
}
|
|
return Buffer.concat(chunks, totalBytes);
|
|
}
|
|
|
|
async function runBoundedRetry(label, operation, params) {
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= params.attempts; attempt += 1) {
|
|
try {
|
|
return await operation(attempt);
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt === params.attempts) {
|
|
break;
|
|
}
|
|
await new Promise((resolvePromise) => {
|
|
setTimeout(resolvePromise, params.delayMs);
|
|
});
|
|
}
|
|
}
|
|
throw new Error(
|
|
`${label} failed after ${params.attempts} attempts: ${
|
|
lastError instanceof Error ? lastError.message : String(lastError)
|
|
}`,
|
|
{ cause: lastError },
|
|
);
|
|
}
|
|
|
|
async function fetchBoundedJson(url, request, params) {
|
|
const response = await request.fetchImpl(url, {
|
|
headers: request.headers,
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(request.timeoutMs),
|
|
});
|
|
const bytes = await readBoundedResponseBody(response, {
|
|
label: params.label,
|
|
maxBytes: params.maxBytes,
|
|
});
|
|
let value;
|
|
try {
|
|
value = JSON.parse(decodeUtf8Exact(bytes, `${params.label} body`));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${params.label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${params.label} JSON must be an object.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function downloadActionsArtifactArchive(params) {
|
|
const expected = requireExpectedBinding(params);
|
|
const token = assertTrimmedString(params.token, "GitHub token");
|
|
const timeoutMs = boundedLimit(params.timeoutMs, DEFAULT_TIMEOUT_MS, "GitHub request timeout");
|
|
const retryAttempts =
|
|
params.retryAttempts === undefined
|
|
? 3
|
|
: boundedLimit(params.retryAttempts, 5, "GitHub request retry count");
|
|
const retryDelayMs =
|
|
params.retryDelayMs === undefined
|
|
? 250
|
|
: boundedLimit(params.retryDelayMs, 5_000, "GitHub retry delay");
|
|
const maxArchiveBytes = boundedLimit(
|
|
params.maxArchiveBytes,
|
|
DEFAULT_MAX_ACTIONS_ARTIFACT_BYTES,
|
|
"Actions artifact ZIP byte limit",
|
|
);
|
|
if (expected.artifactSizeBytes > maxArchiveBytes) {
|
|
throw new Error("Actions artifact size exceeds the configured archive limit.");
|
|
}
|
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
const headers = {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${token}`,
|
|
"user-agent": "openclaw-publication-artifact",
|
|
"x-github-api-version": ACTIONS_ARTIFACT_API_VERSION,
|
|
};
|
|
const apiRoot = `https://api.github.com/repos/${expected.repository}`;
|
|
const request = { fetchImpl, headers, timeoutMs };
|
|
const retry = {
|
|
attempts: retryAttempts,
|
|
delayMs: retryDelayMs,
|
|
};
|
|
const artifactMetadata = await runBoundedRetry(
|
|
"GitHub Actions artifact metadata",
|
|
() =>
|
|
fetchBoundedJson(`${apiRoot}/actions/artifacts/${expected.artifactId}`, request, {
|
|
label: "GitHub Actions artifact metadata",
|
|
maxBytes: DEFAULT_MAX_JSON_BYTES,
|
|
}),
|
|
retry,
|
|
);
|
|
const workflowRun = await runBoundedRetry(
|
|
"GitHub Actions workflow attempt",
|
|
() =>
|
|
fetchBoundedJson(
|
|
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}`,
|
|
request,
|
|
{
|
|
label: "GitHub Actions workflow attempt",
|
|
maxBytes: DEFAULT_MAX_JSON_BYTES,
|
|
},
|
|
),
|
|
retry,
|
|
);
|
|
validateActionsArtifactBinding({ artifactMetadata, expected, workflowRun });
|
|
let workflowJobs;
|
|
if (expected.runStatePolicy === "same-run-producer-success") {
|
|
workflowJobs = await runBoundedRetry(
|
|
"GitHub Actions producer jobs",
|
|
() =>
|
|
fetchBoundedJson(
|
|
`${apiRoot}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}/jobs?per_page=100`,
|
|
request,
|
|
{
|
|
label: "GitHub Actions producer jobs",
|
|
maxBytes: DEFAULT_MAX_JSON_BYTES,
|
|
},
|
|
),
|
|
retry,
|
|
);
|
|
validateActionsArtifactProducerJob({ expected, workflowJobs });
|
|
}
|
|
|
|
const archiveBytes = await runBoundedRetry(
|
|
"GitHub Actions artifact download",
|
|
async () => {
|
|
const response = await fetchImpl(`${apiRoot}/actions/artifacts/${expected.artifactId}/zip`, {
|
|
headers,
|
|
redirect: "follow",
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const bytes = await readBoundedResponseBody(response, {
|
|
expectedBytes: expected.artifactSizeBytes,
|
|
label: "GitHub Actions artifact download",
|
|
maxBytes: maxArchiveBytes,
|
|
});
|
|
const actualDigest = sha256Digest(bytes);
|
|
if (actualDigest !== expected.artifactDigest) {
|
|
throw new Error(
|
|
`GitHub Actions artifact digest ${actualDigest} does not match ${expected.artifactDigest}.`,
|
|
);
|
|
}
|
|
return bytes;
|
|
},
|
|
retry,
|
|
);
|
|
return { archiveBytes, artifactMetadata, binding: expected, workflowJobs, workflowRun };
|
|
}
|
|
|
|
export async function readPublicationArtifactArchive(params) {
|
|
const downloaded = await downloadActionsArtifactArchive(params);
|
|
const files = inspectActionsArtifactZipWithPolicy(downloaded.archiveBytes, params.archivePolicy);
|
|
return { ...downloaded, files };
|
|
}
|