Adapt per review

This commit is contained in:
Reinier Criel 2026-03-27 13:17:58 -07:00
parent 8353f353ae
commit 2df8ce463c
6 changed files with 127 additions and 107 deletions

View file

@ -203,70 +203,6 @@ export function readDatabaseFromLocalCache() {
} }
} }
/**
* @param {import("../api/aikido.js").NewPackageEntry[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeNewPackagesListToLocalCache(data, version) {
try {
const listPath = getNewPackagesListPath();
const versionPath = getNewPackagesListVersionPath();
fs.writeFileSync(listPath, JSON.stringify(data));
fs.writeFileSync(versionPath, version.toString());
} catch {
ui.writeWarning(
"Failed to write new packages list to local cache, next time the list will be fetched from the server again."
);
}
}
/**
* @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}}
*/
export function readNewPackagesListFromLocalCache() {
try {
const listPath = getNewPackagesListPath();
if (!fs.existsSync(listPath)) {
return { newPackagesList: null, version: null };
}
const data = fs.readFileSync(listPath, "utf8");
const newPackagesList = JSON.parse(data);
const versionPath = getNewPackagesListVersionPath();
let version = null;
if (fs.existsSync(versionPath)) {
version = fs.readFileSync(versionPath, "utf8").trim();
}
return { newPackagesList, version };
} catch {
ui.writeWarning(
"Failed to read new packages list from local cache. Continuing without local cache."
);
return { newPackagesList: null, version: null };
}
}
/**
* @returns {string}
*/
function getNewPackagesListPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
}
/**
* @returns {string}
*/
function getNewPackagesListVersionPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
}
/** /**
* @returns {SafeChainConfig} * @returns {SafeChainConfig}
*/ */
@ -312,6 +248,24 @@ function getDatabaseVersionPath() {
return path.join(aikidoDir, `version_${ecosystem}.txt`); return path.join(aikidoDir, `version_${ecosystem}.txt`);
} }
/**
* @returns {string}
*/
export function getNewPackagesListPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
}
/**
* @returns {string}
*/
export function getNewPackagesListVersionPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
}
/** /**
* @returns {string} * @returns {string}
*/ */
@ -332,7 +286,7 @@ function getConfigFilePath() {
/** /**
* @returns {string} * @returns {string}
*/ */
function getSafeChainDirectory() { export function getSafeChainDirectory() {
const homeDir = os.homedir(); const homeDir = os.homedir();
const safeChainDir = path.join(homeDir, ".safe-chain"); const safeChainDir = path.join(homeDir, ".safe-chain");

View file

@ -64,11 +64,11 @@ export async function main(args) {
// Write all buffered logs // Write all buffered logs
ui.writeBufferedLogsAndStopBuffering(); ui.writeBufferedLogsAndStopBuffering();
if (!proxy.verifyNoMaliciousPackages()) { if (proxy.hasBlockedMaliciousPackages()) {
return 1; return 1;
} }
if (!proxy.verifyNoMinimumAgeBlockedRequests()) { if (proxy.hasBlockedMinimumAgeRequests()) {
return 1; return 1;
} }

View file

@ -5,16 +5,29 @@
*/ */
export function parseNpmPackageUrl(url, registry) { export function parseNpmPackageUrl(url, registry) {
let packageName, version; let packageName, version;
const urlWithoutParams = url.split("?")[0].split("#")[0]; let parsedUrl;
if (!registry || !urlWithoutParams.endsWith(".tgz")) { try {
parsedUrl = new URL(url);
} catch {
return { packageName, version }; return { packageName, version };
} }
const registryIndex = urlWithoutParams.indexOf(registry); const pathname = parsedUrl.pathname;
const afterRegistry = decodeURIComponent(urlWithoutParams.substring(
registryIndex + registry.length + 1 if (!registry || !pathname.endsWith(".tgz")) {
)); // +1 to skip the slash return { packageName, version };
}
const registryPrefix = `${registry}/`;
const urlAfterProtocol = `${parsedUrl.host}${pathname}`;
if (!urlAfterProtocol.startsWith(registryPrefix)) {
return { packageName, version };
}
const afterRegistry = decodeURIComponent(
urlAfterProtocol.substring(registryPrefix.length)
);
const separatorIndex = afterRegistry.indexOf("/-/"); const separatorIndex = afterRegistry.indexOf("/-/");
if (separatorIndex === -1) { if (separatorIndex === -1) {

View file

@ -28,8 +28,8 @@ export function createSafeChainProxy() {
return { return {
startServer: () => startServer(server), startServer: () => startServer(server),
stopServer: () => stopServer(server), stopServer: () => stopServer(server),
verifyNoMaliciousPackages, hasBlockedMaliciousPackages,
verifyNoMinimumAgeBlockedRequests, hasBlockedMinimumAgeRequests,
hasSuppressedVersions: getHasSuppressedVersions, hasSuppressedVersions: getHasSuppressedVersions,
}; };
} }
@ -198,10 +198,9 @@ function onMinimumAgeRequestBlocked(packageName, version, url) {
state.blockedMinimumAgeRequests.push({ packageName, version, url }); state.blockedMinimumAgeRequests.push({ packageName, version, url });
} }
function verifyNoMaliciousPackages() { function hasBlockedMaliciousPackages() {
if (state.blockedRequests.length === 0) { if (state.blockedRequests.length === 0) {
// No malicious packages were blocked, so nothing to block return false;
return true;
} }
ui.emptyLine(); ui.emptyLine();
@ -220,12 +219,12 @@ function verifyNoMaliciousPackages() {
ui.writeExitWithoutInstallingMaliciousPackages(); ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine(); ui.emptyLine();
return false; return true;
} }
function verifyNoMinimumAgeBlockedRequests() { function hasBlockedMinimumAgeRequests() {
if (state.blockedMinimumAgeRequests.length === 0) { if (state.blockedMinimumAgeRequests.length === 0) {
return true; return false;
} }
ui.emptyLine(); ui.emptyLine();
@ -252,5 +251,5 @@ function verifyNoMinimumAgeBlockedRequests() {
); );
ui.emptyLine(); ui.emptyLine();
return false; return true;
} }

View file

@ -1,10 +1,11 @@
import fs from "fs";
import { import {
fetchNewPackagesList, fetchNewPackagesList,
fetchNewPackagesListVersion, fetchNewPackagesListVersion,
} from "../api/aikido.js"; } from "../api/aikido.js";
import { import {
readNewPackagesListFromLocalCache, getNewPackagesListPath,
writeNewPackagesListToLocalCache, getNewPackagesListVersionPath,
} from "../config/configFile.js"; } from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { import {
@ -138,3 +139,49 @@ async function getNewPackagesList() {
throw error; throw error;
} }
} }
/**
* @param {import("../api/aikido.js").NewPackageEntry[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeNewPackagesListToLocalCache(data, version) {
try {
const listPath = getNewPackagesListPath();
const versionPath = getNewPackagesListVersionPath();
fs.writeFileSync(listPath, JSON.stringify(data));
fs.writeFileSync(versionPath, version.toString());
} catch {
ui.writeWarning(
"Failed to write new packages list to local cache, next time the list will be fetched from the server again."
);
}
}
/**
* @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}}
*/
export function readNewPackagesListFromLocalCache() {
try {
const listPath = getNewPackagesListPath();
if (!fs.existsSync(listPath)) {
return { newPackagesList: null, version: null };
}
const data = fs.readFileSync(listPath, "utf8");
const newPackagesList = JSON.parse(data);
const versionPath = getNewPackagesListVersionPath();
let version = null;
if (fs.existsSync(versionPath)) {
version = fs.readFileSync(versionPath, "utf8").trim();
}
return { newPackagesList, version };
} catch {
ui.writeWarning(
"Failed to read new packages list from local cache. Continuing without local cache."
);
return { newPackagesList: null, version: null };
}
}

View file

@ -1,9 +1,10 @@
import { describe, it, mock, beforeEach } from "node:test"; import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import fs from "fs";
import path from "path";
import os from "os";
// --- shared mutable state for mocks --- // --- shared mutable state for mocks ---
let cachedList = null;
let cachedVersion = null;
let fetchedList = []; let fetchedList = [];
let fetchedVersion = "etag-1"; let fetchedVersion = "etag-1";
let fetchVersionResult = "etag-1"; let fetchVersionResult = "etag-1";
@ -13,6 +14,7 @@ let writeWarningCalls = [];
let fetchListError = null; let fetchListError = null;
let fetchVersionError = null; let fetchVersionError = null;
let importCounter = 0; let importCounter = 0;
let testHomeDir = "";
mock.module("../api/aikido.js", { mock.module("../api/aikido.js", {
namedExports: { namedExports: {
@ -36,16 +38,6 @@ mock.module("../api/aikido.js", {
}, },
}); });
mock.module("../config/configFile.js", {
namedExports: {
readNewPackagesListFromLocalCache: () => ({
newPackagesList: cachedList,
version: cachedVersion,
}),
writeNewPackagesListToLocalCache: () => {},
},
});
mock.module("../environment/userInteraction.js", { mock.module("../environment/userInteraction.js", {
namedExports: { namedExports: {
ui: { ui: {
@ -66,8 +58,6 @@ mock.module("../config/settings.js", {
describe("newPackagesDatabase", async () => { describe("newPackagesDatabase", async () => {
beforeEach(() => { beforeEach(() => {
cachedList = null;
cachedVersion = null;
fetchedList = []; fetchedList = [];
fetchedVersion = "etag-1"; fetchedVersion = "etag-1";
fetchVersionResult = "etag-1"; fetchVersionResult = "etag-1";
@ -76,6 +66,13 @@ describe("newPackagesDatabase", async () => {
writeWarningCalls = []; writeWarningCalls = [];
fetchListError = null; fetchListError = null;
fetchVersionError = null; fetchVersionError = null;
testHomeDir = path.join(
os.tmpdir(),
`safe-chain-new-packages-db-${process.pid}-${importCounter}`
);
fs.rmSync(testHomeDir, { recursive: true, force: true });
fs.mkdirSync(testHomeDir, { recursive: true });
process.env.HOME = testHomeDir;
}); });
async function openNewPackagesDatabase() { async function openNewPackagesDatabase() {
@ -93,6 +90,19 @@ describe("newPackagesDatabase", async () => {
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000); return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
} }
function writeCachedList(list, version) {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_${ecosystem}.json`),
JSON.stringify(list)
);
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`),
version
);
}
describe("isNewlyReleasedPackage", () => { describe("isNewlyReleasedPackage", () => {
it("returns true for a package released within the age threshold", async () => { it("returns true for a package released within the age threshold", async () => {
fetchedList = [ fetchedList = [
@ -171,10 +181,9 @@ describe("newPackagesDatabase", async () => {
describe("caching behaviour", () => { describe("caching behaviour", () => {
it("uses local cache when etag matches", async () => { it("uses local cache when etag matches", async () => {
cachedList = [ writeCachedList([
{ package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, { package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
]; ], "etag-1");
cachedVersion = "etag-1";
fetchVersionResult = "etag-1"; fetchVersionResult = "etag-1";
// fetchedList is empty — if we used the remote list, the lookup would return false // fetchedList is empty — if we used the remote list, the lookup would return false
fetchedList = []; fetchedList = [];
@ -184,10 +193,9 @@ describe("newPackagesDatabase", async () => {
}); });
it("fetches fresh list when etag does not match", async () => { it("fetches fresh list when etag does not match", async () => {
cachedList = [ writeCachedList([
{ package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, { package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
]; ], "etag-old");
cachedVersion = "etag-old";
fetchVersionResult = "etag-new"; fetchVersionResult = "etag-new";
fetchedList = [ fetchedList = [
{ package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) }, { package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1), scraped_on: hoursAgo(1) },
@ -199,15 +207,14 @@ describe("newPackagesDatabase", async () => {
}); });
it("falls back to local cache when fetch fails", async () => { it("falls back to local cache when fetch fails", async () => {
cachedList = [ writeCachedList([
{ {
package_name: "cached-pkg", package_name: "cached-pkg",
version: "1.0.0", version: "1.0.0",
released_on: hoursAgo(1), released_on: hoursAgo(1),
scraped_on: hoursAgo(1), scraped_on: hoursAgo(1),
}, },
]; ], "etag-old");
cachedVersion = "etag-old";
fetchVersionResult = "etag-new"; fetchVersionResult = "etag-new";
fetchListError = new Error("Network error"); fetchListError = new Error("Network error");