Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/github-draft-release-v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ jobs:
"Memmy-$VERSION-darwin-arm64-cn-signed.dmg"
"Memmy-$VERSION-darwin-arm64-intl-signed.dmg"
)
: > release-assets/OSS_VERIFICATION.jsonl

for artifact in "${artifacts[@]}"; do
url="$base/$artifact"
Expand All @@ -404,12 +405,6 @@ jobs:
echo "::error title=Installer asset is missing::$artifact was not found at $url. Manual recovery: confirm the packaging/upload workflow finished for version $VERSION, then re-run this workflow." >&2
exit 1
fi
content_md5="$(awk 'BEGIN { IGNORECASE=1 } /^Content-MD5:/ { gsub("\\r", "", $2); value=$2 } END { print value }' "$headers")"
if [[ -z "$content_md5" ]]; then
echo "::error title=Installer checksum header missing::OSS did not return Content-MD5 for $artifact. Manual recovery: verify the OSS object metadata or re-upload the installer." >&2
exit 1
fi

if ! curl --fail --location --retry 5 --retry-all-errors \
--output "release-assets/$artifact" "$url"; then
echo "::error title=Installer download failed::Could not download $artifact after retries. Manual recovery: check OSS/CDN availability, then re-run this workflow." >&2
Expand All @@ -420,14 +415,16 @@ jobs:
exit 1
fi

expected_md5="$(printf '%s' "$content_md5" | base64 --decode | xxd -p -c 256)"
actual_md5="$(md5sum "release-assets/$artifact" | awk '{print $1}')"
if [[ "$actual_md5" != "$expected_md5" ]]; then
echo "::error title=Installer checksum mismatch::Content-MD5 mismatch for $artifact. Manual recovery: do not publish; rebuild or re-upload the installer, then re-run." >&2
if ! integrity="$(node scripts/verify-oss-object-integrity.mjs "$headers" "release-assets/$artifact")"; then
echo "::error title=Installer checksum verification failed::$artifact did not match its fail-closed OSS integrity metadata. Normal objects require Content-MD5; Multipart objects require x-oss-hash-crc64ecma. Manual recovery: do not publish; repair or re-upload the object, then re-run." >&2
exit 1
fi
printf '%s\n' "$integrity" \
| jq -c --arg artifact "$artifact" '. + {artifact: $artifact}' \
>> release-assets/OSS_VERIFICATION.jsonl
done

jq -s '.' release-assets/OSS_VERIFICATION.jsonl > release-assets/OSS_VERIFICATION.json
(cd release-assets && md5sum Memmy-* > MD5SUMS.txt)
(cd release-assets && sha256sum Memmy-* > SHA256SUMS.txt)

Expand Down Expand Up @@ -710,7 +707,7 @@ jobs:

## Checksums

Verify downloads with MD5SUMS.txt or SHA256SUMS.txt attached to this release. The workflow also verifies every OSS object against its Content-MD5 header before publishing.
Verify downloads with MD5SUMS.txt or SHA256SUMS.txt attached to this release. Before creating the Draft, the workflow verifies Normal OSS objects with Content-MD5 and Multipart objects with OSS CRC-64/XZ.

<!-- doc-agent: source-id=memmy-official-changelog-v2 -->
<!-- memmy-release-evidence
Expand Down Expand Up @@ -793,6 +790,7 @@ jobs:
--slurpfile compare release-assets/COMPARE.json \
--slurpfile pullRequests release-assets/PULL_REQUESTS.json \
--slurpfile artifacts release-assets/ARTIFACTS.json \
--slurpfile ossIntegrity release-assets/OSS_VERIFICATION.json \
'{
schema: "memmy.release.evidence.v2",
schemaVersion: 2,
Expand Down Expand Up @@ -837,7 +835,8 @@ jobs:
releaseNotesSource: $releaseNotesSource,
releaseNotesNeedsReview: $releaseNotesNeedsReview,
artifactManifest: "SHA256SUMS.txt",
artifacts: $artifacts[0]
artifacts: $artifacts[0],
ossIntegrity: $ossIntegrity[0]
}' > release-assets/RELEASE_EVIDENCE.json

{
Expand Down Expand Up @@ -866,6 +865,7 @@ jobs:
release-assets/TARGET_COMMIT_METADATA.json
release-assets/COMPARE.json
release-assets/PULL_REQUESTS.json
release-assets/OSS_VERIFICATION.json
release-assets/RELEASE_EVIDENCE.json
release-assets/MD5SUMS.txt
release-assets/SHA256SUMS.txt
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"lint": "npm run memory:lint && npm run workspace:lint",
"typecheck": "npm run memory:lint && npm run workspace:typecheck",
"test": "npm run test:release-workflow && npm run test:packaging-guards && npm run memory:test && npm run workspace:test && npm run agent:test:tui-cursor",
"test:release-workflow": "vitest run tests/release-workflow.test.ts",
"test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs",
"test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs",
"serve": "npm run memory:serve",
"serve:local": "npm run memory:serve:local",
Expand Down
49 changes: 49 additions & 0 deletions scripts/internal/shared/oss-crc64.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createReadStream } from "node:fs";

const MASK_64 = 0xffffffffffffffffn;
const REFLECTED_POLYNOMIAL = 0xc96c5795d7870f42n;

const TABLE = Array.from({ length: 256 }, (_, byte) => {
let value = BigInt(byte);
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1n) === 1n
? (value >> 1n) ^ REFLECTED_POLYNOMIAL
: value >> 1n;
}
return value & MASK_64;
});

function updateCrc64Xz(state, bytes) {
let next = state;
for (const byte of bytes) {
const index = Number((next ^ BigInt(byte)) & 0xffn);
next = TABLE[index] ^ (next >> 8n);
}
return next & MASK_64;
}

export function crc64Xz(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new TypeError("CRC64/XZ input must be a Uint8Array or Buffer");
}
return (updateCrc64Xz(MASK_64, bytes) ^ MASK_64) & MASK_64;
}

export async function crc64XzFile(path) {
let state = MASK_64;
for await (const chunk of createReadStream(path)) {
state = updateCrc64Xz(state, chunk);
}
return (state ^ MASK_64) & MASK_64;
}

export function parseUnsignedCrc64(value) {
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,19})$/.test(value)) {
throw new Error("Expected CRC64 must be an unsigned decimal integer");
}
const parsed = BigInt(value);
if (parsed > MASK_64) {
throw new Error("Expected CRC64 exceeds the unsigned 64-bit range");
}
return parsed;
}
150 changes: 150 additions & 0 deletions scripts/internal/shared/oss-object-integrity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { lstat } from "node:fs/promises";
import { crc64XzFile, parseUnsignedCrc64 } from "./oss-crc64.mjs";

const TRACKED_HEADERS = new Set([
"content-length",
"content-md5",
"etag",
"x-oss-hash-crc64ecma",
"x-oss-object-type",
]);

export function parseFinalOssHeadHeaders(source) {
if (typeof source !== "string") {
throw new Error("OSS HEAD response must be text");
}

const blocks = [];
let current = null;
for (const rawLine of source.split(/\r?\n/)) {
const statusMatch = /^HTTP\/\S+\s+(\d{3})(?:\s|$)/i.exec(rawLine);
if (statusMatch) {
current = { status: Number(statusMatch[1]), headers: new Map() };
blocks.push(current);
continue;
}
if (!current || !rawLine) continue;
const separator = rawLine.indexOf(":");
if (separator < 1) continue;
const name = rawLine.slice(0, separator).trim().toLowerCase();
if (!TRACKED_HEADERS.has(name)) continue;
const values = current.headers.get(name) ?? [];
values.push(rawLine.slice(separator + 1).trim());
current.headers.set(name, values);
}

const finalBlock = blocks.at(-1);
if (!finalBlock) {
throw new Error("OSS HEAD response does not contain an HTTP status line");
}
if (finalBlock.status < 200 || finalBlock.status >= 300) {
throw new Error(`OSS final HEAD response has unexpected HTTP status ${finalBlock.status}`);
}

const value = (name) => {
const values = finalBlock.headers.get(name) ?? [];
if (values.length > 1) {
throw new Error(`OSS final HEAD response contains duplicate ${name} headers`);
}
return values[0] ?? "";
};

return {
status: finalBlock.status,
contentLength: value("content-length"),
contentMd5: value("content-md5"),
crc64: value("x-oss-hash-crc64ecma"),
etag: value("etag"),
objectType: value("x-oss-object-type"),
};
}

async function md5File(path) {
const hash = createHash("md5");
for await (const chunk of createReadStream(path)) {
hash.update(chunk);
}
return hash.digest();
}

function parseContentMd5(value) {
if (!/^[A-Za-z0-9+/]{22}==$/.test(value)) {
throw new Error("Normal OSS object is missing a canonical Content-MD5 value");
}
const decoded = Buffer.from(value, "base64");
if (decoded.length !== 16 || decoded.toString("base64") !== value) {
throw new Error("Normal OSS object returned an invalid Content-MD5 value");
}
return decoded;
}

function parseContentLength(value) {
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) {
throw new Error("OSS object is missing a valid Content-Length value");
}
const parsed = BigInt(value);
if (parsed <= 0n) {
throw new Error("OSS installer object must be non-empty");
}
return parsed;
}

export async function verifyOssObjectIntegrity(headersText, path) {
const metadata = parseFinalOssHeadHeaders(headersText);
const expectedSize = parseContentLength(metadata.contentLength);
const file = await lstat(path);
if (file.isSymbolicLink() || !file.isFile()) {
throw new Error("Downloaded OSS installer must be a regular, non-symbolic-link file");
}
const actualSize = BigInt(file.size);
if (actualSize !== expectedSize) {
throw new Error(`OSS Content-Length mismatch: expected ${expectedSize}, actual ${actualSize}`);
}

if (metadata.objectType === "Normal") {
const expected = parseContentMd5(metadata.contentMd5);
const actual = await md5File(path);
if (!actual.equals(expected)) {
throw new Error(
`Content-MD5 mismatch: expected ${expected.toString("hex")}, actual ${actual.toString("hex")}`,
);
}
return {
objectType: metadata.objectType,
method: "content-md5",
expected: expected.toString("hex"),
actual: actual.toString("hex"),
size: file.size,
etag: metadata.etag,
};
}

if (metadata.objectType === "Multipart") {
let expected;
try {
expected = parseUnsignedCrc64(metadata.crc64);
} catch (error) {
throw new Error(
`Multipart OSS object is missing a valid x-oss-hash-crc64ecma value: ${error instanceof Error ? error.message : String(error)}`,
);
}
const actual = await crc64XzFile(path);
if (actual !== expected) {
throw new Error(`CRC64/XZ mismatch: expected ${expected}, actual ${actual}`);
}
return {
objectType: metadata.objectType,
method: "crc64-xz",
expected: expected.toString(10),
actual: actual.toString(10),
size: file.size,
etag: metadata.etag,
};
}

throw new Error(
`Unsupported OSS object type '${metadata.objectType || "missing"}'; expected Normal or Multipart`,
);
}
21 changes: 21 additions & 0 deletions scripts/verify-oss-object-integrity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node

import { readFile } from "node:fs/promises";
import { verifyOssObjectIntegrity } from "./internal/shared/oss-object-integrity.mjs";

const [headersPath, filePath, ...extra] = process.argv.slice(2);
if (!headersPath || !filePath || extra.length > 0) {
console.error(
"Usage: node scripts/verify-oss-object-integrity.mjs <head-response-file> <downloaded-file>",
);
process.exit(2);
}

try {
const headers = await readFile(headersPath, "utf8");
const result = await verifyOssObjectIntegrity(headers, filePath);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
Loading