Skip to content
Open
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
4 changes: 4 additions & 0 deletions knife
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ function cmd_update_node_archives () {
| sort_by(.date) | reverse | .[0].version
')
latest_nov=$(echo "$latest_version" | sed 's/^v//')
if [[ ! "$latest_nov" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid Node.js version from release index: $latest_version" >&2
exit 1
fi
versions+=("$latest_nov")
done

Expand Down
128 changes: 103 additions & 25 deletions knife.d/update_node_archives.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,61 @@ const crypto = require("crypto");
const https = require("https");
const fs = require("fs");

if (process.argv.length < 3) {
console.error("Usage: node nodeChecksum.js <nodejs_version>");
process.exit(1);
}

const versions = process.argv[2].split(",");
let versions = [];
const architectures = ["amd64", "arm64", "arm", "ppc64le", "s390x"];
const NODE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
const ARCHIVE_SUFFIX_RE = /^(x64|arm64|armv7l|ppc64le|s390x)$/;
const DISTROLESS_ARCH_RE = /^(amd64|arm64|arm|ppc64le|s390x)$/;
const SHA256_RE = /^[0-9a-f]{64}$/;

const nodeVersions = {};

const starlarkString = (value) => {
return `"${String(value)
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")}"`;
};

const validateNodeVersion = (version) => {
if (!NODE_VERSION_RE.test(version)) {
throw new Error(`Invalid Node.js version: ${version}`);
}
return version;
};

const validateArchiveSuffix = (suffix) => {
if (!ARCHIVE_SUFFIX_RE.test(suffix)) {
throw new Error(`Invalid Node.js archive suffix: ${suffix}`);
}
return suffix;
};

const validateDistrolessArch = (arch) => {
if (!DISTROLESS_ARCH_RE.test(arch)) {
throw new Error(`Invalid distroless architecture: ${arch}`);
}
return arch;
};

const validateSha256 = (sha256) => {
if (!SHA256_RE.test(sha256)) {
throw new Error(`Invalid SHA256 checksum: ${sha256}`);
}
return sha256;
};

const calculateChecksum = (url) => {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`Request failed with status code ${res.statusCode}`));
return;
}
const hash = crypto.createHash("sha256");
res.on("data", (data) => {
hash.update(data);
Expand All @@ -33,6 +74,7 @@ const calculateChecksum = (url) => {

const fetchChecksums = async () => {
for (const nodeVersion of versions) {
validateNodeVersion(nodeVersion);
const major = parseInt(nodeVersion.split(".")[0]);
nodeVersions[nodeVersion] = {};
await Promise.all(
Expand All @@ -46,6 +88,7 @@ const fetchChecksums = async () => {
} else if (key === "arm") {
arch = "armv7l";
}
validateArchiveSuffix(arch);
const url = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-${arch}.tar.gz`;
try {
const checksum = await calculateChecksum(url);
Expand All @@ -54,14 +97,38 @@ const fetchChecksums = async () => {
suffix: arch,
};
} catch (error) {
console.error(error);
throw new Error(`Failed to fetch checksum for ${url}: ${error}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The calculateChecksum function (defined around line 52) does not validate the HTTP response status code (res.statusCode).\n\nIf the server returns a non-200 status code (such as 404 Not Found or 500 Internal Server Error), https.get will still succeed without triggering the 'error' event. As a result, calculateChecksum will calculate and return the SHA256 hash of the error response body (e.g., the HTML error page) instead of rejecting. This defeats the "fail fast" behavior intended by this PR, as invalid checksums will be silently generated and written to the Starlark file.\n\nTo fix this, please update calculateChecksum to reject the promise if res.statusCode !== 200. For example:\n\njavascript\nconst calculateChecksum = (url) => {\n return new Promise((resolve, reject) => {\n https\n .get(url, (res) => {\n if (res.statusCode !== 200) {\n reject(new Error(\"Request failed with status code \" + res.statusCode));\n return;\n }\n const hash = crypto.createHash(\"sha256\");\n res.on(\"data\", (data) => {\n hash.update(data);\n });\n res.on(\"end\", () => {\n resolve(hash.digest(\"hex\"));\n });\n })\n .on(\"error\", (err) => {\n reject(\"Error downloading file: \" + err.message);\n });\n });\n};\n

}
})
);
}
};

fetchChecksums().then(() => {
const validateFetchedChecksums = () => {
for (const nodeVersion of versions) {
const major = parseInt(nodeVersion.split(".")[0]);
for (const arch of architectures) {
if (major > 22 && arch === "arm") {
continue;
}
if (!nodeVersions[nodeVersion] || !nodeVersions[nodeVersion][arch]) {
throw new Error(
`Failed to fetch checksum for Node.js version ${nodeVersion} on ${arch}`
);
}
}
}
};

const main = async () => {
if (process.argv.length < 3) {
console.error("Usage: node nodeChecksum.js <nodejs_version>");
process.exit(1);
}
versions = process.argv[2].split(",");
await fetchChecksums();
validateFetchedChecksums();

let nodeArchives = `"node"

BUILD_TMPL = """\\
Expand Down Expand Up @@ -172,16 +239,19 @@ def _node_impl(module_ctx):
}
const arch = nodeVersions[nodeVersion][key];
const url = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}-linux-${arch.suffix}.tar.gz`;
validateDistrolessArch(key);
validateArchiveSuffix(arch.suffix);
validateSha256(arch.checksum);

nodeArchives += "\n";
nodeArchives += `
node_archive(
name = "nodejs${major}_${key}",
sha256 = "${arch.checksum}",
strip_prefix = "node-v${nodeVersion}-linux-${arch.suffix}/",
urls = ["${url}"],
version = "${nodeVersion}",
architecture = "${key}",
name = ${starlarkString(`nodejs${major}_${key}`)},
sha256 = ${starlarkString(arch.checksum)},
strip_prefix = ${starlarkString(`node-v${nodeVersion}-linux-${arch.suffix}/`)},
urls = [${starlarkString(url)}],
version = ${starlarkString(nodeVersion)},
architecture = ${starlarkString(key)},
control = "//nodejs:control",
)`;
}
Expand All @@ -194,11 +264,7 @@ commandTests:
expectedOutput: ["v${nodeVersion}"]
`;

fs.writeFile(`nodejs/testdata/nodejs${major}.yaml`, testData, (err) => {
if (err) {
console.error(err);
}
});
fs.writeFileSync(`nodejs/testdata/nodejs${major}.yaml`, testData);
}
nodeArchives += `

Expand All @@ -212,7 +278,7 @@ commandTests:
continue;
}
nodeArchives += `
"nodejs${major}_${arch}",`;
${starlarkString(`nodejs${major}_${arch}`)},`;
}
}

Expand All @@ -232,9 +298,21 @@ node = module_extension(
`;

// Write output to node_archives.bzl file
fs.writeFile("private/extensions/node.bzl", nodeArchives, (err) => {
if (err) {
console.error(err);
}
fs.writeFileSync("private/extensions/node.bzl", nodeArchives);
};

if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
});
}

module.exports = {
calculateChecksum,
starlarkString,
validateArchiveSuffix,
validateDistrolessArch,
validateNodeVersion,
validateSha256,
};
82 changes: 82 additions & 0 deletions knife.d/update_node_archives_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env node
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const https = require("https");

const {
calculateChecksum,
starlarkString,
validateArchiveSuffix,
validateDistrolessArch,
validateNodeVersion,
validateSha256,
} = require("./update_node_archives.js");

assert.equal(validateNodeVersion("26.2.0"), "26.2.0");
assert.throws(
() => validateNodeVersion('26.2.0" + "bad'),
/Invalid Node.js version/
);
assert.throws(() => validateNodeVersion("v26.2.0"), /Invalid Node.js version/);
assert.throws(() => validateNodeVersion("26.2.0\n26.2.1"), /Invalid Node.js version/);

assert.equal(validateArchiveSuffix("x64"), "x64");
assert.throws(() => validateArchiveSuffix('x64" + fail("bad") + "'), /Invalid Node.js archive suffix/);

assert.equal(validateDistrolessArch("amd64"), "amd64");
assert.throws(() => validateDistrolessArch("amd64\nbad"), /Invalid distroless architecture/);

assert.equal(
validateSha256("a".repeat(64)),
"a".repeat(64)
);
assert.throws(() => validateSha256("not-a-sha"), /Invalid SHA256 checksum/);

assert.equal(
starlarkString('26.2.0" + "bad'),
'"26.2.0\\" + \\"bad"'
);
assert.equal(starlarkString("line1\nline2"), '"line1\\nline2"');

const withMockedHttpsGet = async (mock, fn) => {
const originalGet = https.get;
https.get = mock;
try {
await fn();
} finally {
https.get = originalGet;
}
};

const mockHttpsGetStatus = (statusCode) => (_url, onResponse) => {
const req = new EventEmitter();
process.nextTick(() => {
const res = new EventEmitter();
res.statusCode = statusCode;
res.resume = () => {};
onResponse(res);
process.nextTick(() => {
res.emit("data", Buffer.from("error body"));
res.emit("end");
});
});
return req;
};

const runAsyncTests = async () => {
await withMockedHttpsGet(mockHttpsGetStatus(404), async () => {
await assert.rejects(
calculateChecksum("https://nodejs.org/dist/not-found.tar.gz"),
/Request failed with status code 404/
);
});
};

runAsyncTests()
.then(() => {
console.log("update_node_archives validation tests passed");
})
.catch((err) => {
console.error(err);
process.exit(1);
});