Fix some border cases

This commit is contained in:
Reinier Criel 2025-10-23 09:14:05 -07:00
parent 1b82aeb6b0
commit 1fdb15a392
9 changed files with 685 additions and 46 deletions

View file

@ -9,7 +9,7 @@ import {
createPnpxPackageManager,
} from "./pnpm/createPackageManager.js";
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPipPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js";
const state = {
packageManagerName: null,

View file

@ -4,7 +4,8 @@ import { runPip } from "./runPipCommand.js";
import {
getPipCommandForArgs,
pipInstallCommand,
pipUninstallCommand,
pipDownloadCommand,
pipWheelCommand,
} from "./utils/pipCommands.js";
/**
@ -38,7 +39,9 @@ export function createPipPackageManager(command = "pip") {
const commandScannerMapping = {
[pipInstallCommand]: commandArgumentScanner(),
[pipUninstallCommand]: nullScanner(), // Uninstall doesn't need scanning
[pipDownloadCommand]: commandArgumentScanner(), // download also fetches packages from PyPI
[pipWheelCommand]: commandArgumentScanner(), // wheel downloads and builds packages
// Other commands (uninstall, list, etc.) will use nullScanner() by default
};
function findDependencyScannerForCommand(scanners, args) {

View file

@ -0,0 +1,117 @@
import { test } from "node:test";
import assert from "node:assert";
import { createPipPackageManager } from "./createPackageManager.js";
test("createPipPackageManager", async (t) => {
await t.test("should create package manager with default pip command", () => {
const pm = createPipPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
await t.test("should create package manager with custom pip3 command", () => {
const pm = createPipPackageManager("pip3");
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
});
await t.test("should recognize install command as supported", () => {
const pm = createPipPackageManager();
// Note: Currently returns false because commandArgumentScanner is not yet implemented
// When implemented, this should return true
const result = pm.isSupportedCommand(["install", "requests"]);
assert.strictEqual(typeof result, "boolean");
});
await t.test("should recognize download command as supported", () => {
const pm = createPipPackageManager();
const result = pm.isSupportedCommand(["download", "requests"]);
assert.strictEqual(typeof result, "boolean");
});
await t.test("should recognize wheel command as supported", () => {
const pm = createPipPackageManager();
const result = pm.isSupportedCommand(["wheel", "requests"]);
assert.strictEqual(typeof result, "boolean");
});
await t.test("should not support uninstall command", () => {
const pm = createPipPackageManager();
const result = pm.isSupportedCommand(["uninstall", "requests"]);
assert.strictEqual(result, false);
});
await t.test("should not support list command", () => {
const pm = createPipPackageManager();
const result = pm.isSupportedCommand(["list"]);
assert.strictEqual(result, false);
});
await t.test("should not support show command", () => {
const pm = createPipPackageManager();
const result = pm.isSupportedCommand(["show", "requests"]);
assert.strictEqual(result, false);
});
await t.test("should return empty array for getDependencyUpdatesForCommand on install", () => {
const pm = createPipPackageManager();
// Note: Currently returns [] because commandArgumentScanner is not yet implemented
const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]);
assert.ok(Array.isArray(result));
});
await t.test("should return empty array for getDependencyUpdatesForCommand on download", () => {
const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["download", "flask"]);
assert.ok(Array.isArray(result));
});
await t.test("should return empty array for getDependencyUpdatesForCommand on wheel", () => {
const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["wheel", "django"]);
assert.ok(Array.isArray(result));
});
await t.test("should return empty array for unsupported commands", () => {
const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]);
assert.strictEqual(Array.isArray(result), true);
assert.strictEqual(result.length, 0);
});
await t.test("should handle empty args array", () => {
const pm = createPipPackageManager();
const supported = pm.isSupportedCommand([]);
assert.strictEqual(supported, false);
const deps = pm.getDependencyUpdatesForCommand([]);
assert.ok(Array.isArray(deps));
assert.strictEqual(deps.length, 0);
});
await t.test("should handle args with only flags", () => {
const pm = createPipPackageManager();
const supported = pm.isSupportedCommand(["--version"]);
assert.strictEqual(supported, false);
const deps = pm.getDependencyUpdatesForCommand(["-h", "--help"]);
assert.ok(Array.isArray(deps));
assert.strictEqual(deps.length, 0);
});
});

View file

@ -1,3 +1,6 @@
import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js";
import { hasDryRunArg } from "../utils/pipCommands.js";
/**
* Scanner for pip command arguments to detect package installations
*
@ -9,15 +12,11 @@ export function commandArgumentScanner(options = {}) {
const { ignoreDryRun = false } = options;
function shouldScan(args) {
// For now, pip scanning is not yet implemented
// This would need to detect 'install' commands and package arguments
return false;
return shouldScanDependencies(args, ignoreDryRun);
}
function scan(args) {
// Future implementation would parse pip install arguments
// and return array of {name, version, type} objects
return [];
return scanDependencies(args);
}
return {
@ -25,3 +24,28 @@ export function commandArgumentScanner(options = {}) {
scan,
};
}
function shouldScanDependencies(args, ignoreDryRun) {
return ignoreDryRun || !hasDryRunArg(args);
}
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
/**
* Extracts package changes from pip command arguments
*
* Unlike npm, pip's parser already returns exact versions (== or ===)
* or "latest" for unversioned packages, so no version resolution is needed.
*
* @param {string[]} args - Command line arguments
* @returns {Array<{name: string, version: string, type: string}>} Package changes
*/
export function checkChangesFromArgs(args) {
const packageUpdates = parsePackagesFromInstallArgs(args);
// Parser already provides exact versions or "latest", no need to resolve
// Just return the packages with type "add"
return packageUpdates;
}

View file

@ -0,0 +1,215 @@
import { test } from "node:test";
import assert from "node:assert";
import { commandArgumentScanner, checkChangesFromArgs } from "./commandArgumentScanner.js";
test("commandArgumentScanner", async (t) => {
await t.test("should create scanner with default options", () => {
const scanner = commandArgumentScanner();
assert.ok(scanner);
assert.strictEqual(typeof scanner.shouldScan, "function");
assert.strictEqual(typeof scanner.scan, "function");
});
await t.test("should create scanner with ignoreDryRun option", () => {
const scanner = commandArgumentScanner({ ignoreDryRun: true });
assert.ok(scanner);
assert.strictEqual(typeof scanner.shouldScan, "function");
assert.strictEqual(typeof scanner.scan, "function");
});
});
test("shouldScan", async (t) => {
await t.test("should return true for normal install command", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["install", "requests"]);
assert.strictEqual(result, true);
});
await t.test("should return false for install with --dry-run", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["install", "--dry-run", "requests"]);
assert.strictEqual(result, false);
});
await t.test("should return true for install with --dry-run when ignoreDryRun is true", () => {
const scanner = commandArgumentScanner({ ignoreDryRun: true });
const result = scanner.shouldScan(["install", "--dry-run", "requests"]);
assert.strictEqual(result, true);
});
await t.test("should return true for download command", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["download", "flask"]);
assert.strictEqual(result, true);
});
await t.test("should return true for wheel command", () => {
const scanner = commandArgumentScanner();
const result = scanner.shouldScan(["wheel", "django"]);
assert.strictEqual(result, true);
});
});
test("scan", async (t) => {
await t.test("should scan simple package installation", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "latest",
type: "add",
});
});
await t.test("should scan package with exact version", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests==2.28.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
await t.test("should scan multiple packages", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests==2.28.0", "flask"]);
assert.strictEqual(result.length, 2);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
assert.deepEqual(result[1], {
name: "flask",
version: "latest",
type: "add",
});
});
await t.test("should skip packages with range specifiers", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests>=2.0.0", "flask==2.0.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "flask",
version: "2.0.0",
type: "add",
});
});
await t.test("should skip flags with parameters", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan([
"install",
"-r",
"requirements.txt",
"requests==2.28.0",
]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
await t.test("should work with download command", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["download", "django==4.2.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "django",
version: "4.2.0",
type: "add",
});
});
await t.test("should work with wheel command", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["wheel", "numpy==1.24.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "numpy",
version: "1.24.0",
type: "add",
});
});
await t.test("should parse packages even for unsupported commands", () => {
const scanner = commandArgumentScanner();
// Note: The parser treats the first non-flag arg as the command and skips it
// So "uninstall" is treated as the command, and "requests" is parsed as a package
// The scanner itself doesn't filter by command type - that's done at a higher level
const result = scanner.scan(["uninstall", "requests"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "latest",
type: "add",
});
});
await t.test("should handle === exact version specifier", () => {
const scanner = commandArgumentScanner();
const result = scanner.scan(["install", "requests===2.28.0"]);
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
});
});
test("checkChangesFromArgs", async (t) => {
await t.test("should extract changes from install args", () => {
const result = checkChangesFromArgs(["install", "requests==2.28.0", "flask"]);
assert.strictEqual(result.length, 2);
assert.deepEqual(result[0], {
name: "requests",
version: "2.28.0",
type: "add",
});
assert.deepEqual(result[1], {
name: "flask",
version: "latest",
type: "add",
});
});
await t.test("should return empty array for commands with no packages", () => {
const result = checkChangesFromArgs(["install", "-r", "requirements.txt"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
await t.test("should handle empty args", () => {
const result = checkChangesFromArgs([]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
});

View file

@ -1,17 +1,26 @@
/**
* Parses package specifications from pip install arguments
*
* Pip supports various package specification formats:
* - package_name
* - package_name==version
* - package_name>=version
* - package_name~=version
* - git+https://...
* - -r requirements.txt
* - . (local directory)
* Only returns packages with exact version specifiers (== or ===) to ensure
* we can check specific versions against the malware database.
*
* Supported formats that will be returned:
* - package_name (no version)
* - package_name==version (exact version)
* - package_name===version (exact version, PEP 440)
*
* Skipped formats (won't be returned):
* - package_name>=version (range specifier)
* - package_name<=version (range specifier)
* - package_name>version (range specifier)
* - package_name<version (range specifier)
* - package_name~=version (compatible release)
* - package_name!=version (exclusion)
* - git+https://... (VCS URLs - returned without version)
* - -r requirements.txt (handled by flag skipping)
*
* @param {string[]} args - pip install command arguments
* @returns {Array<{name: string, version?: string, type: string}>} Array of package specifications
* @returns {Array<{name: string, version?: string, type: string}>} Array of package specifications with exact versions only
*/
export function parsePackagesFromInstallArgs(args) {
const packages = [];
@ -32,31 +41,126 @@ export function parsePackagesFromInstallArgs(args) {
// Skip flags and their values
if (arg.startsWith("-")) {
// Some flags take a value, skip the next arg for those
if (arg === "-r" || arg === "--requirement" ||
arg === "-c" || arg === "--constraint" ||
arg === "-e" || arg === "--editable" ||
arg === "-t" || arg === "--target" ||
arg === "-i" || arg === "--index-url" ||
arg === "--extra-index-url") {
// Flags that take a value - skip the next arg for those
if (isPipOptionWithParameter(arg)) {
skipNext = true;
}
continue;
}
// TODO: Implement full parsing logic
// For now, this is a placeholder that would need to handle:
// - Version specifiers (==, >=, <=, ~=, !=, <, >)
// - VCS urls (git+, hg+, svn+, bzr+)
// - Local file paths
// - Requirements files (-r, --requirement)
// - Extras (package[extra1,extra2])
packages.push({
name: arg,
type: "add",
});
const parsed = parsePipSpec(arg);
if (parsed) {
packages.push({ ...parsed, type: "add" });
}
}
return packages;
}
// Check if a pip flag takes a parameter
function isPipOptionWithParameter(arg) {
const optionsWithParameters = [
// Install options
"-r",
"--requirement",
"-c",
"--constraint",
"-e",
"--editable",
"-t",
"--target",
"--platform",
"--python-version",
"--implementation",
"--abi",
"--root",
"--prefix",
"--src",
"--upgrade-strategy",
"--progress-bar",
"--root-user-action",
"--report",
"--group",
// Package index options
"-i",
"--index-url",
"--extra-index-url",
"-f",
"--find-links",
// General options
"--python",
"--log",
"--keyring-provider",
"--proxy",
"--retries",
"--timeout",
"--exists-action",
"--trusted-host",
"--cert",
"--client-cert",
"--cache-dir",
"--use-feature",
"--use-deprecated",
"--resume-retries",
];
return optionsWithParameters.includes(arg);
}
// Parse a single pip requirement spec
// Always returns { name, version } where version defaults to "latest" if not specified
function parsePipSpec(spec) {
// Ignore obvious URLs and paths
// These cannot be scanned from the malware database
const lower = spec.toLowerCase();
if (
lower.startsWith("git+") ||
lower.startsWith("hg+") ||
lower.startsWith("svn+") ||
lower.startsWith("bzr+") ||
lower.startsWith("http:") ||
lower.startsWith("https:") ||
lower.startsWith("file:") ||
spec.startsWith("./") ||
spec.startsWith("../") ||
spec.startsWith("/")
) {
return { name: spec, version: "latest" };
}
// Strip extras: package[extra1,extra2]
const extrasStart = spec.indexOf("[");
const extrasEnd = extrasStart >= 0 ? spec.indexOf("]", extrasStart) : -1;
let base = spec;
if (extrasStart >= 0 && extrasEnd > extrasStart) {
base = spec.slice(0, extrasStart) + spec.slice(extrasEnd + 1);
}
// Split on first occurrence of a comparator or comma spec
// Support multi-constraint lists like ">=1,<2" by detecting the first comparator
const comparatorRegex = /(===|==|!=|~=|>=|<=|<|>)/;
const m = base.match(comparatorRegex);
if (!m) {
// No comparator => just a name, use "latest" as version
return { name: base, version: "latest" };
}
const idx = m.index;
const name = base.slice(0, idx);
const versionPart = base.slice(idx); // e.g. '==2.28.0' or '>=1,<2'
// Normalize whitespace inside versionPart
const versionWithOperator = versionPart.replace(/\s+/g, "");
// Only return packages with exact version specifiers (== or ===)
// Skip range specifiers (<, >, <=, >=, ~=, !=) since they don't provide a specific version
if (!versionWithOperator.startsWith("==")) {
return null;
}
// Strip the == or === operator to get just the version number
const version = versionWithOperator.replace(/^===?/, "");
return { name, version };
}

View file

@ -6,30 +6,82 @@ describe("parsePackagesFromInstallArgs", () => {
it("should parse simple package name", () => {
const result = parsePackagesFromInstallArgs(["install", "requests"]);
assert.deepEqual(result, [
{ name: "requests", type: "add" },
{ name: "requests", version: "latest", type: "add" },
]);
});
it("should parse package with version specifier", () => {
const result = parsePackagesFromInstallArgs(["install", "requests==2.28.0"]);
assert.deepEqual(result, [
{ name: "requests==2.28.0", type: "add" },
{ name: "requests", version: "2.28.0", type: "add" },
]);
});
it("should skip flags", () => {
const result = parsePackagesFromInstallArgs(["install", "--upgrade", "requests"]);
assert.deepEqual(result, [
{ name: "requests", type: "add" },
{ name: "requests", version: "latest", type: "add" },
]);
});
it("should parse multiple packages", () => {
const result = parsePackagesFromInstallArgs(["install", "requests", "flask", "django==4.0"]);
assert.deepEqual(result, [
{ name: "requests", type: "add" },
{ name: "flask", type: "add" },
{ name: "django==4.0", type: "add" },
{ name: "requests", version: "latest", type: "add" },
{ name: "flask", version: "latest", type: "add" },
{ name: "django", version: "4.0", type: "add" },
]);
});
it("should parse extras and strip them from name", () => {
const result = parsePackagesFromInstallArgs(["install", "django[postgres]==4.2.1"]);
assert.deepEqual(result, [
{ name: "django", version: "4.2.1", type: "add" },
]);
});
it("should parse multiple constraints", () => {
const result = parsePackagesFromInstallArgs(["install", "requests>=2,<3"]);
// Range specifiers should be skipped since they don't provide exact versions
assert.deepEqual(result, []);
});
it("should skip packages with range specifiers", () => {
const result = parsePackagesFromInstallArgs([
"install",
"requests>=2.0.0",
"flask>1.0",
"django<=4.0",
"numpy~=1.20",
"scipy!=1.5.0",
"pandas==1.3.0",
]);
// Only pandas with exact version (==) should be returned
assert.deepEqual(result, [
{ name: "pandas", version: "1.3.0", type: "add" },
]);
});
it("should support === exact version specifier", () => {
const result = parsePackagesFromInstallArgs(["install", "requests===2.28.0"]);
assert.deepEqual(result, [
{ name: "requests", version: "2.28.0", type: "add" },
]);
});
it("should treat VCS/URL/path specs as names (no version)", () => {
const result = parsePackagesFromInstallArgs([
"install",
"git+https://github.com/pallets/flask.git",
"https://files.pythonhosted.org/packages/foo/bar.whl",
"file:/tmp/pkg.whl",
"./localpkg",
]);
assert.deepEqual(result, [
{ name: "git+https://github.com/pallets/flask.git", version: "latest", type: "add" },
{ name: "https://files.pythonhosted.org/packages/foo/bar.whl", version: "latest", type: "add" },
{ name: "file:/tmp/pkg.whl", version: "latest", type: "add" },
{ name: "./localpkg", version: "latest", type: "add" },
]);
});
@ -37,4 +89,28 @@ describe("parsePackagesFromInstallArgs", () => {
const result = parsePackagesFromInstallArgs(["install", "--help"]);
assert.deepEqual(result, []);
});
it("should skip all flags with parameters", () => {
const result = parsePackagesFromInstallArgs([
"install",
"--target",
"/tmp/target",
"--platform",
"linux",
"--python-version",
"3.9",
"--index-url",
"https://pypi.org/simple",
"--trusted-host",
"pypi.org",
"requests==2.28.0",
"--cache-dir",
"/tmp/cache",
"flask",
]);
assert.deepEqual(result, [
{ name: "requests", version: "2.28.0", type: "add" },
{ name: "flask", version: "latest", type: "add" },
]);
});
});

View file

@ -1,11 +1,13 @@
/**
* Pip command constants
*
* Note: Unlike npm, pip does not support command aliases or abbreviations.
* Commands must be spelled out fully (e.g., "install", not "i" or "add").
*/
export const pipInstallCommand = "install";
export const pipDownloadCommand = "download";
export const pipWheelCommand = "wheel";
export const pipUninstallCommand = "uninstall";
export const pipListCommand = "list";
export const pipShowCommand = "show";
export const pipFreeze = "freeze";
/**
* Gets the pip command from the arguments array
@ -27,3 +29,13 @@ export function getPipCommandForArgs(args) {
return null;
}
/**
* Checks if the arguments contain the --dry-run flag
*
* @param {string[]} args - Command line arguments
* @returns {boolean} True if --dry-run is present
*/
export function hasDryRunArg(args) {
return args.some((arg) => arg === "--dry-run");
}

View file

@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert";
import {
getPipCommandForArgs,
hasDryRunArg,
pipInstallCommand,
pipDownloadCommand,
pipWheelCommand,
pipUninstallCommand,
} from "./pipCommands.js";
test("getPipCommandForArgs", async (t) => {
await t.test("should return null for empty args", () => {
assert.strictEqual(getPipCommandForArgs([]), null);
});
await t.test("should return null for null args", () => {
assert.strictEqual(getPipCommandForArgs(null), null);
});
await t.test("should return the first non-flag argument", () => {
assert.strictEqual(getPipCommandForArgs(["install"]), "install");
});
await t.test("should skip flags and return command", () => {
assert.strictEqual(
getPipCommandForArgs(["-v", "--verbose", "install"]),
"install"
);
});
await t.test("should return install command", () => {
assert.strictEqual(
getPipCommandForArgs(["install", "requests"]),
"install"
);
});
await t.test("should return uninstall command", () => {
assert.strictEqual(
getPipCommandForArgs(["uninstall", "requests"]),
"uninstall"
);
});
await t.test("should return null if only flags", () => {
assert.strictEqual(getPipCommandForArgs(["--version", "-v"]), null);
});
});
test("hasDryRunArg", async (t) => {
await t.test("should return false for empty args", () => {
assert.strictEqual(hasDryRunArg([]), false);
});
await t.test("should return true if --dry-run is present", () => {
assert.strictEqual(hasDryRunArg(["install", "--dry-run", "requests"]), true);
});
await t.test("should return false if --dry-run is not present", () => {
assert.strictEqual(hasDryRunArg(["install", "requests"]), false);
});
await t.test("should return true for --dry-run with other flags", () => {
assert.strictEqual(
hasDryRunArg(["install", "-v", "--dry-run", "--upgrade", "requests"]),
true
);
});
});
test("command constants", async (t) => {
await t.test("should have correct install command", () => {
assert.strictEqual(pipInstallCommand, "install");
});
await t.test("should have correct download command", () => {
assert.strictEqual(pipDownloadCommand, "download");
});
await t.test("should have correct wheel command", () => {
assert.strictEqual(pipWheelCommand, "wheel");
});
await t.test("should have correct uninstall command", () => {
assert.strictEqual(pipUninstallCommand, "uninstall");
});
});