995 lines
31 KiB
TypeScript
995 lines
31 KiB
TypeScript
type PackageManager = "pnpm" | "npm" | "yarn";
|
|
|
|
type ManagerParseResult = Readonly<{
|
|
dependencies: readonly string[];
|
|
unsafeLifecycle: boolean;
|
|
unsupportedManagerSyntax: boolean;
|
|
}>;
|
|
|
|
type SuppressionState = {
|
|
effective: boolean | undefined;
|
|
contradictory: boolean;
|
|
malformed: boolean;
|
|
};
|
|
|
|
type ShellNpmScopeEnvironmentState = {
|
|
autoExport: boolean;
|
|
forbidden: boolean;
|
|
uncertain: boolean;
|
|
};
|
|
|
|
type ShellCommandPrefix = Readonly<{
|
|
assignments: readonly Readonly<{
|
|
dynamicName: boolean;
|
|
name: string | null;
|
|
}>[];
|
|
commandIndex: number;
|
|
uncertain: boolean;
|
|
}>;
|
|
|
|
type EnvironmentCommandPrefix = Readonly<{
|
|
assignmentNames: readonly string[];
|
|
uncertain: boolean;
|
|
}>;
|
|
|
|
type TokenizedShellSegment = Readonly<{
|
|
tokens: readonly string[];
|
|
expansionTokens: readonly boolean[];
|
|
}>;
|
|
|
|
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
|
|
const managerOptionsWithValue = new Set([
|
|
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
|
|
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
|
|
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
|
|
]);
|
|
const managerBooleanOptions = new Set([
|
|
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
|
|
"--use-stderr", "--verbose", "-g", "-s",
|
|
]);
|
|
const npmScriptDispatchBooleanOptions = new Set([
|
|
"--foreground-scripts", "--if-present", "--ignore-scripts",
|
|
]);
|
|
const npmScriptDispatchScopeOptions = new Set([
|
|
"--prefix", "--workspace", "--workspaces",
|
|
]);
|
|
const npmDispatchScopeEnvironmentNames = new Set([
|
|
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
|
|
"npm_config_workspace", "npm_config_workspaces",
|
|
]);
|
|
const npmIndirectConfigAuthorityOptions = new Set([
|
|
"--globalconfig", "--userconfig",
|
|
]);
|
|
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
|
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
|
|
npm: new Set(["--prefix", "--workspace"]),
|
|
yarn: new Set(["--cwd"]),
|
|
};
|
|
const lifecycleMutationCommands = new Set([
|
|
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
|
|
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
|
|
]);
|
|
const managerBuiltinAliases: Readonly<
|
|
Record<PackageManager, ReadonlyMap<string, string>>
|
|
> = {
|
|
pnpm: new Map([["ln", "link"]]),
|
|
npm: new Map(),
|
|
yarn: new Map(),
|
|
};
|
|
const unsupportedBuiltinDispatchers: Readonly<
|
|
Record<PackageManager, ReadonlySet<string>>
|
|
> = {
|
|
pnpm: new Set(["dlx", "exec"]),
|
|
npm: new Set(["exec"]),
|
|
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
|
|
};
|
|
const lifecycleBooleanOptions: Readonly<
|
|
Record<PackageManager, ReadonlySet<string>>
|
|
> = {
|
|
pnpm: new Set([
|
|
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
|
|
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
|
|
"--workspace-root", "-D", "-P", "-r", "-w",
|
|
]),
|
|
npm: new Set([
|
|
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
|
|
"--package-lock-only",
|
|
]),
|
|
yarn: new Set([
|
|
"--check-cache", "--frozen-lockfile", "--ignore-engines",
|
|
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
|
|
"--no-lockfile", "--non-interactive", "--pure-lockfile",
|
|
]),
|
|
};
|
|
const lifecycleOptionsWithValue: Readonly<
|
|
Record<PackageManager, ReadonlySet<string>>
|
|
> = {
|
|
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
|
|
npm: new Set(["--include", "--install-strategy", "--omit"]),
|
|
yarn: new Set(["--mode", "--modules-folder", "--production"]),
|
|
};
|
|
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
|
pnpm: new Set([
|
|
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
|
|
"outdated", "root", "server", "setup", "store", "view", "why",
|
|
]),
|
|
npm: new Set([
|
|
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
|
|
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
|
|
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
|
|
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
|
|
"team", "token", "unstar", "version", "view", "whoami",
|
|
]),
|
|
yarn: new Set([
|
|
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
|
|
"npm", "plugin", "set", "stage", "version", "why",
|
|
]),
|
|
};
|
|
const exactBareSafeBuiltinCommands: Readonly<
|
|
Record<PackageManager, ReadonlySet<string>>
|
|
> = {
|
|
pnpm: new Set(["audit"]),
|
|
npm: new Set(["audit"]),
|
|
yarn: new Set(),
|
|
};
|
|
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
|
|
|
|
export function validatePackageScriptGraph(
|
|
scripts: Readonly<Record<string, string>>,
|
|
entryScript: string,
|
|
): string[] {
|
|
const failures: string[] = [];
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
const stack: string[] = [];
|
|
|
|
const visit = (scriptName: string): void => {
|
|
if (visiting.has(scriptName)) {
|
|
const start = stack.indexOf(scriptName);
|
|
failures.push(`package script cycle: ${[...stack.slice(start), scriptName].join(" -> ")}`);
|
|
return;
|
|
}
|
|
if (visited.has(scriptName)) return;
|
|
const command = scripts[scriptName];
|
|
if (command === undefined) {
|
|
failures.push(`package script missing: ${scriptName}`);
|
|
return;
|
|
}
|
|
visiting.add(scriptName);
|
|
stack.push(scriptName);
|
|
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
|
|
failures.push(`${scriptName} must not invoke the CI gate runner`);
|
|
}
|
|
const parsed = parseManagerCommands(command, scripts);
|
|
if (parsed.unsupportedManagerSyntax) {
|
|
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
|
|
}
|
|
for (const dependency of parsed.dependencies) {
|
|
if (dependency === "ci:gate") {
|
|
failures.push(`${scriptName} must not invoke ci:gate`);
|
|
} else if (!(dependency in scripts)) {
|
|
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
|
|
} else {
|
|
visit(dependency);
|
|
}
|
|
}
|
|
stack.pop();
|
|
visiting.delete(scriptName);
|
|
visited.add(scriptName);
|
|
};
|
|
|
|
visit(entryScript);
|
|
return [...new Set(failures)];
|
|
}
|
|
|
|
export function validateInstallScriptPolicy(
|
|
scripts: Readonly<Record<string, string>>,
|
|
entryScripts: readonly string[],
|
|
): string[] {
|
|
const failures: string[] = [];
|
|
const visited = new Set<string>();
|
|
const visit = (scriptName: string): void => {
|
|
if (visited.has(scriptName)) return;
|
|
visited.add(scriptName);
|
|
const command = scripts[scriptName];
|
|
if (command === undefined) {
|
|
failures.push(`package script missing: ${scriptName}`);
|
|
return;
|
|
}
|
|
const parsed = parseManagerCommands(command, scripts);
|
|
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
|
|
failures.push(
|
|
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
|
|
);
|
|
}
|
|
for (const dependency of parsed.dependencies) {
|
|
if (dependency !== "ci:gate") visit(dependency);
|
|
}
|
|
};
|
|
for (const entryScript of entryScripts) visit(entryScript);
|
|
return [...new Set(failures)];
|
|
}
|
|
|
|
export function validateNpmScopeEnvironment(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
): string[] {
|
|
return Object.keys(environment)
|
|
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
|
|
.map((name) => `npm scope environment is not allowed: ${name}`);
|
|
}
|
|
|
|
function parseManagerCommands(
|
|
command: string,
|
|
scripts: Readonly<Record<string, string>>,
|
|
): ManagerParseResult {
|
|
const tokenized = tokenizeShellSegments(command);
|
|
const dependencies: string[] = [];
|
|
let unsafeLifecycle = false;
|
|
let unsupportedManagerSyntax = false;
|
|
if (!tokenized) {
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([]),
|
|
unsafeLifecycle: false,
|
|
unsupportedManagerSyntax: containsManagerReference(command),
|
|
});
|
|
}
|
|
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
|
|
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
|
|
autoExport: false,
|
|
forbidden: false,
|
|
uncertain: false,
|
|
};
|
|
for (const segment of tokenized.segments) {
|
|
const { tokens, expansionTokens } = segment;
|
|
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens[index]!;
|
|
if (token === "corepack") {
|
|
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
|
unsupportedManagerSyntax = true;
|
|
break;
|
|
}
|
|
const wrapped = tokens[index + 1];
|
|
if (!wrapped || !isPackageManager(wrapped)) {
|
|
unsupportedManagerSyntax = true;
|
|
break;
|
|
}
|
|
const parsed = parseManagerInvocation(
|
|
wrapped,
|
|
tokens,
|
|
expansionTokens,
|
|
index + 2,
|
|
scripts,
|
|
hasUnsafeNpmScopeEnvironment(
|
|
npmScopeEnvironmentState,
|
|
tokens,
|
|
expansionTokens,
|
|
index,
|
|
),
|
|
);
|
|
dependencies.push(...parsed.dependencies);
|
|
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
|
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
|
break;
|
|
}
|
|
if (isPackageManager(token)) {
|
|
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
|
unsupportedManagerSyntax = true;
|
|
break;
|
|
}
|
|
const parsed = parseManagerInvocation(
|
|
token,
|
|
tokens,
|
|
expansionTokens,
|
|
index + 1,
|
|
scripts,
|
|
hasUnsafeNpmScopeEnvironment(
|
|
npmScopeEnvironmentState,
|
|
tokens,
|
|
expansionTokens,
|
|
index,
|
|
),
|
|
);
|
|
dependencies.push(...parsed.dependencies);
|
|
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
|
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
|
break;
|
|
}
|
|
if (containsManagerReference(token)) {
|
|
unsupportedManagerSyntax = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([...new Set(dependencies)]),
|
|
unsafeLifecycle,
|
|
unsupportedManagerSyntax,
|
|
});
|
|
}
|
|
|
|
function parseManagerInvocation(
|
|
manager: PackageManager,
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
start: number,
|
|
scripts: Readonly<Record<string, string>>,
|
|
hasNpmScopeEnvironment: boolean,
|
|
): ManagerParseResult {
|
|
let cursor = start;
|
|
let changesManifestScope = false;
|
|
let hasNpmConfigAuthority = false;
|
|
let consumedManagerSyntax = false;
|
|
const suppression: SuppressionState = {
|
|
effective: undefined,
|
|
contradictory: false,
|
|
malformed: false,
|
|
};
|
|
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
|
consumedManagerSyntax = true;
|
|
const option = tokens[cursor]!;
|
|
const parsedSuppression = consumeSuppressionOption(
|
|
manager,
|
|
tokens,
|
|
cursor,
|
|
suppression,
|
|
);
|
|
if (parsedSuppression.recognized) {
|
|
if (parsedSuppression.unsupported) return unsupportedResult();
|
|
cursor = parsedSuppression.nextIndex;
|
|
continue;
|
|
}
|
|
const equals = option.indexOf("=");
|
|
const name = equals < 0 ? option : option.slice(0, equals);
|
|
if (managerOptionsWithValue.has(name)) {
|
|
changesManifestScope ||= manifestScopeOptions[manager].has(name);
|
|
hasNpmConfigAuthority ||=
|
|
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
|
|
if (equals >= 0) {
|
|
if (option.slice(equals + 1).length === 0) return unsupportedResult();
|
|
} else {
|
|
cursor += 1;
|
|
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
|
|
return unsupportedResult();
|
|
}
|
|
}
|
|
} else if (managerBooleanOptions.has(name)) {
|
|
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
|
|
return unsupportedResult();
|
|
}
|
|
} else if (option !== "--") {
|
|
return unsupportedResult();
|
|
}
|
|
cursor += 1;
|
|
}
|
|
const subcommand = tokens[cursor];
|
|
if (!subcommand) return unsupportedResult();
|
|
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
|
|
return unsupportedResult();
|
|
}
|
|
const argumentsAfterCommand = tokens.slice(cursor + 1);
|
|
if (subcommand === "run" || subcommand === "run-script") {
|
|
const dependency = argumentsAfterCommand[0];
|
|
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
|
|
if (changesManifestScope) return unsupportedResult();
|
|
if (
|
|
manager === "npm" &&
|
|
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
|
|
!areNpmScriptDispatchArgumentsSupported(
|
|
argumentsAfterCommand.slice(1),
|
|
expansionTokens.slice(cursor + 2),
|
|
suppression,
|
|
))
|
|
) {
|
|
return unsupportedResult();
|
|
}
|
|
return manager === "npm"
|
|
? npmScriptDependencyResult(dependency, scripts, suppression)
|
|
: dependencyResult(dependency);
|
|
}
|
|
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
|
|
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
|
|
return unsupportedResult();
|
|
}
|
|
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
|
|
const lifecycleArgumentsSupported = parseLifecycleArguments(
|
|
manager,
|
|
argumentsAfterCommand,
|
|
suppression,
|
|
);
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([]),
|
|
unsafeLifecycle:
|
|
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
|
|
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
|
|
});
|
|
}
|
|
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
|
|
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
|
|
!consumedManagerSyntax &&
|
|
argumentsAfterCommand.length === 0
|
|
? emptyResult()
|
|
: unsupportedResult();
|
|
}
|
|
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
|
|
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
|
|
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
|
|
(manager === "npm" && npmImplicitScripts.has(subcommand))
|
|
);
|
|
if (supportsImplicit) {
|
|
if (changesManifestScope) return unsupportedResult();
|
|
if (
|
|
manager === "npm" &&
|
|
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
|
|
!areNpmScriptDispatchArgumentsSupported(
|
|
argumentsAfterCommand,
|
|
expansionTokens.slice(cursor + 1),
|
|
suppression,
|
|
))
|
|
) {
|
|
return unsupportedResult();
|
|
}
|
|
return manager === "npm"
|
|
? npmScriptDependencyResult(subcommand, scripts, suppression)
|
|
: dependencyResult(subcommand);
|
|
}
|
|
return unsupportedResult();
|
|
}
|
|
|
|
function areNpmScriptDispatchArgumentsSupported(
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
suppression: SuppressionState,
|
|
): boolean {
|
|
let index = 0;
|
|
while (index < tokens.length) {
|
|
const token = tokens[index]!;
|
|
if (token === "--") return true;
|
|
if (expansionTokens[index]) return false;
|
|
if (!token.startsWith("-")) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const parsedSuppression = consumeSuppressionOption(
|
|
"npm",
|
|
tokens,
|
|
index,
|
|
suppression,
|
|
);
|
|
if (parsedSuppression.recognized) {
|
|
if (parsedSuppression.unsupported) return false;
|
|
index = parsedSuppression.nextIndex;
|
|
continue;
|
|
}
|
|
|
|
const equals = token.indexOf("=");
|
|
const name = equals < 0 ? token : token.slice(0, equals);
|
|
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
|
|
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
|
|
return false;
|
|
}
|
|
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
|
|
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
|
|
return false;
|
|
}
|
|
index += 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function hasUnsafeNpmScopeEnvironment(
|
|
state: Readonly<ShellNpmScopeEnvironmentState>,
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
commandIndex: number,
|
|
): boolean {
|
|
return state.forbidden || state.uncertain ||
|
|
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
|
|
}
|
|
|
|
function hasUnsafeManagerCommandPrefix(
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
commandIndex: number,
|
|
): boolean {
|
|
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
|
if (prefix.uncertain) return true;
|
|
let cursor = prefix.commandIndex;
|
|
if (cursor === commandIndex) return false;
|
|
if (cursor > commandIndex ||
|
|
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
|
|
return true;
|
|
}
|
|
return parseEnvironmentCommandPrefix(
|
|
tokens,
|
|
expansionTokens,
|
|
cursor,
|
|
commandIndex,
|
|
).uncertain;
|
|
}
|
|
|
|
function hasUnsafeImmediateNpmScopeEnvironment(
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
commandIndex: number,
|
|
): boolean {
|
|
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
|
if (prefix.uncertain || prefix.assignments.some(
|
|
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
|
|
)) return true;
|
|
let cursor = prefix.commandIndex;
|
|
if (cursor === commandIndex) return false;
|
|
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
|
|
const environmentPrefix = parseEnvironmentCommandPrefix(
|
|
tokens,
|
|
expansionTokens,
|
|
cursor,
|
|
commandIndex,
|
|
);
|
|
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
|
|
(name) => isNpmScopeEnvironmentName(name),
|
|
);
|
|
}
|
|
|
|
function parseEnvironmentCommandPrefix(
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
start: number,
|
|
commandIndex: number,
|
|
): EnvironmentCommandPrefix {
|
|
const assignmentNames: string[] = [];
|
|
let cursor = start + 1;
|
|
let uncertain = false;
|
|
|
|
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
|
|
const option = tokens[cursor]!;
|
|
if (expansionTokens[cursor]) uncertain = true;
|
|
if (option === "--") {
|
|
cursor += 1;
|
|
break;
|
|
}
|
|
if (option === "-i" || option === "--ignore-environment") {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
if (option === "-u" || option === "--unset") {
|
|
cursor += 1;
|
|
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
|
|
uncertain = true;
|
|
break;
|
|
}
|
|
uncertain ||= expansionTokens[cursor] ?? false;
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
if (/^--unset=.+/u.test(option)) {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
uncertain = true;
|
|
cursor += 1;
|
|
}
|
|
|
|
while (cursor < commandIndex) {
|
|
const token = tokens[cursor]!;
|
|
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
|
|
uncertain = true;
|
|
}
|
|
const assignmentName = parseEnvironmentAssignmentName(token);
|
|
if (assignmentName) assignmentNames.push(assignmentName);
|
|
else uncertain = true;
|
|
cursor += 1;
|
|
}
|
|
|
|
return Object.freeze({
|
|
assignmentNames: Object.freeze(assignmentNames),
|
|
uncertain,
|
|
});
|
|
}
|
|
|
|
function parseShellCommandPrefix(
|
|
tokens: readonly string[],
|
|
expansionTokens: readonly boolean[],
|
|
): ShellCommandPrefix {
|
|
const assignments: Array<{
|
|
dynamicName: boolean;
|
|
name: string | null;
|
|
}> = [];
|
|
let cursor = 0;
|
|
let uncertain = false;
|
|
while (cursor < tokens.length) {
|
|
const token = tokens[cursor]!;
|
|
const name = parseAssignmentName(token);
|
|
const dynamicName = hasDynamicAssignmentName(
|
|
token,
|
|
expansionTokens[cursor] ?? false,
|
|
);
|
|
if (!name && !dynamicName) break;
|
|
assignments.push({ dynamicName, name });
|
|
uncertain ||= dynamicName;
|
|
cursor += 1;
|
|
}
|
|
|
|
while (cursor < tokens.length) {
|
|
const wrapper = tokens[cursor];
|
|
if (expansionTokens[cursor]) {
|
|
uncertain = true;
|
|
break;
|
|
}
|
|
if (wrapper !== "command" && wrapper !== "exec") break;
|
|
cursor += 1;
|
|
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
|
const option = tokens[cursor]!;
|
|
if (option === "--") {
|
|
cursor += 1;
|
|
break;
|
|
}
|
|
if (wrapper === "command" && option === "-p") {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
uncertain = true;
|
|
cursor += 1;
|
|
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
|
|
cursor += 1;
|
|
}
|
|
}
|
|
}
|
|
if (expansionTokens[cursor]) uncertain = true;
|
|
return Object.freeze({
|
|
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
|
|
commandIndex: cursor,
|
|
uncertain,
|
|
});
|
|
}
|
|
|
|
function updateShellNpmScopeEnvironmentState(
|
|
segment: TokenizedShellSegment,
|
|
state: ShellNpmScopeEnvironmentState,
|
|
): void {
|
|
const { tokens, expansionTokens } = segment;
|
|
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
|
state.uncertain ||= prefix.uncertain;
|
|
const command = tokens[prefix.commandIndex];
|
|
if (!command) {
|
|
for (const assignment of prefix.assignments) {
|
|
if (state.autoExport && assignment.name &&
|
|
isNpmScopeEnvironmentName(assignment.name)) {
|
|
state.forbidden = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === "eval" || command === "." || command === "source") {
|
|
state.uncertain = true;
|
|
return;
|
|
}
|
|
if (command === "unset" || command === "typeset" || command === "declare" ||
|
|
command === "local" || command === "readonly") {
|
|
state.uncertain = true;
|
|
return;
|
|
}
|
|
if (command === "set") {
|
|
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
|
|
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
|
|
state.autoExport = false;
|
|
state.uncertain = true;
|
|
}
|
|
return;
|
|
}
|
|
if (command === "export") {
|
|
let cursor = prefix.commandIndex + 1;
|
|
for (; cursor < tokens.length; cursor += 1) {
|
|
const token = tokens[cursor]!;
|
|
if (token === "--") continue;
|
|
if (token === "-n" || token.startsWith("-")) {
|
|
state.uncertain = true;
|
|
continue;
|
|
}
|
|
const assignmentName = parseAssignmentName(token);
|
|
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
|
|
if (assignmentName || bareName) {
|
|
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
|
|
state.forbidden = true;
|
|
}
|
|
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
|
|
expansionTokens[cursor]) {
|
|
state.uncertain = true;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
|
|
if (!token || hasExpansion) return false;
|
|
return token.split("/").at(-1) === "env";
|
|
}
|
|
|
|
function parseEnvironmentAssignmentName(token: string): string | null {
|
|
const equals = token.indexOf("=");
|
|
return equals > 0 ? token.slice(0, equals) : null;
|
|
}
|
|
|
|
function isNpmScopeEnvironmentName(name: string): boolean {
|
|
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
|
|
}
|
|
|
|
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
|
|
const equals = token.indexOf("=");
|
|
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
|
|
}
|
|
|
|
function parseAssignmentName(token: string): string | null {
|
|
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
|
|
}
|
|
|
|
function parseLifecycleArguments(
|
|
manager: PackageManager,
|
|
tokens: readonly string[],
|
|
suppression: SuppressionState,
|
|
): boolean {
|
|
let cursor = 0;
|
|
while (cursor < tokens.length) {
|
|
const token = tokens[cursor]!;
|
|
if (!token.startsWith("-")) {
|
|
cursor += 1;
|
|
continue;
|
|
}
|
|
const parsedSuppression = consumeSuppressionOption(
|
|
manager,
|
|
tokens,
|
|
cursor,
|
|
suppression,
|
|
);
|
|
if (parsedSuppression.recognized) {
|
|
if (parsedSuppression.unsupported) return false;
|
|
cursor = parsedSuppression.nextIndex;
|
|
continue;
|
|
}
|
|
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
|
|
if (parsedOption === null) return false;
|
|
cursor = parsedOption;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function consumeSuppressionOption(
|
|
manager: PackageManager,
|
|
tokens: readonly string[],
|
|
index: number,
|
|
state: SuppressionState,
|
|
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
|
|
const token = tokens[index]!;
|
|
if (token === "--no-ignore-scripts") {
|
|
recordSuppression(state, false);
|
|
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
|
}
|
|
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
|
|
for (const prefix of equalsForms) {
|
|
if (!token.startsWith(prefix)) continue;
|
|
if (prefix.startsWith("--config.") && manager !== "pnpm") {
|
|
state.malformed = true;
|
|
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
|
}
|
|
const raw = token.slice(prefix.length);
|
|
if (raw !== "true" && raw !== "false") {
|
|
state.malformed = true;
|
|
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
|
}
|
|
recordSuppression(state, raw === "true");
|
|
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
|
}
|
|
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
|
|
return { recognized: false, unsupported: false, nextIndex: index };
|
|
}
|
|
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
|
|
state.malformed = true;
|
|
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
|
}
|
|
const next = tokens[index + 1];
|
|
if (next === "true" || next === "false") {
|
|
const supportsSplitValue = manager === "npm" || manager === "pnpm";
|
|
if (!supportsSplitValue) {
|
|
state.malformed = true;
|
|
return { recognized: true, unsupported: true, nextIndex: index + 2 };
|
|
}
|
|
recordSuppression(state, next === "true");
|
|
return { recognized: true, unsupported: false, nextIndex: index + 2 };
|
|
}
|
|
recordSuppression(state, true);
|
|
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
|
}
|
|
|
|
function recordSuppression(
|
|
state: SuppressionState,
|
|
value: boolean,
|
|
): void {
|
|
if (state.effective !== undefined && state.effective !== value) {
|
|
state.contradictory = true;
|
|
}
|
|
state.effective = value;
|
|
}
|
|
|
|
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
|
|
return state.effective === true && !state.contradictory && !state.malformed;
|
|
}
|
|
|
|
function consumeAllowedLifecycleOption(
|
|
manager: PackageManager,
|
|
tokens: readonly string[],
|
|
index: number,
|
|
): number | null {
|
|
const option = tokens[index]!;
|
|
const equals = option.indexOf("=");
|
|
const name = equals < 0 ? option : option.slice(0, equals);
|
|
const booleanOption =
|
|
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
|
|
if (booleanOption) {
|
|
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
|
|
return index + 1;
|
|
}
|
|
const valuedOption =
|
|
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
|
|
if (!valuedOption) return null;
|
|
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
|
|
const value = tokens[index + 1];
|
|
if (!value || value.startsWith("-")) return null;
|
|
return index + 2;
|
|
}
|
|
|
|
function dependencyResult(dependency: string): ManagerParseResult {
|
|
return dependenciesResult([dependency]);
|
|
}
|
|
|
|
function npmScriptDependencyResult(
|
|
dependency: string,
|
|
scripts: Readonly<Record<string, string>>,
|
|
suppression: SuppressionState,
|
|
): ManagerParseResult {
|
|
if (hasEffectiveLifecycleSuppression(suppression)) {
|
|
return dependenciesResult([dependency]);
|
|
}
|
|
return dependenciesResult(
|
|
[`pre${dependency}`, dependency, `post${dependency}`]
|
|
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
|
|
);
|
|
}
|
|
|
|
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([...dependencies]),
|
|
unsafeLifecycle: false,
|
|
unsupportedManagerSyntax: false,
|
|
});
|
|
}
|
|
|
|
function emptyResult(): ManagerParseResult {
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([]),
|
|
unsafeLifecycle: false,
|
|
unsupportedManagerSyntax: false,
|
|
});
|
|
}
|
|
|
|
function unsupportedResult(): ManagerParseResult {
|
|
return Object.freeze({
|
|
dependencies: Object.freeze([]),
|
|
unsafeLifecycle: false,
|
|
unsupportedManagerSyntax: true,
|
|
});
|
|
}
|
|
|
|
function isPackageManager(value: string): value is PackageManager {
|
|
return managerNames.has(value as PackageManager);
|
|
}
|
|
|
|
function containsManagerReference(value: string): boolean {
|
|
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
|
|
.test(value);
|
|
}
|
|
|
|
function tokenizeShellSegments(command: string): Readonly<{
|
|
segments: readonly TokenizedShellSegment[];
|
|
unsupportedControl: boolean;
|
|
}> | null {
|
|
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
|
|
{ tokens: [], expansionTokens: [] },
|
|
];
|
|
let token = "";
|
|
let tokenHasExpansion = false;
|
|
let quote: "'" | '"' | null = null;
|
|
let escaping = false;
|
|
let unsupportedControl = false;
|
|
const pushToken = (): void => {
|
|
if (token.length > 0) {
|
|
segments.at(-1)!.tokens.push(token);
|
|
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
|
|
}
|
|
token = "";
|
|
tokenHasExpansion = false;
|
|
};
|
|
const pushSegment = (): void => {
|
|
pushToken();
|
|
if (segments.at(-1)!.tokens.length > 0) {
|
|
segments.push({ tokens: [], expansionTokens: [] });
|
|
}
|
|
};
|
|
for (let index = 0; index < command.length; index += 1) {
|
|
const character = command[index]!;
|
|
if (escaping) {
|
|
token += character;
|
|
escaping = false;
|
|
continue;
|
|
}
|
|
if (character === "\\" && quote !== "'") {
|
|
escaping = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (character === quote) quote = null;
|
|
else {
|
|
if (quote === '"' && character === "$") tokenHasExpansion = true;
|
|
token += character;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === "'" || character === '"') {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
|
|
unsupportedControl = true;
|
|
if (character === "$") tokenHasExpansion = true;
|
|
token += character;
|
|
continue;
|
|
}
|
|
if (character === "$" || character === "*" || character === "?" || character === "[") {
|
|
tokenHasExpansion = true;
|
|
}
|
|
if (character === "#") {
|
|
unsupportedControl = true;
|
|
pushToken();
|
|
while (
|
|
index + 1 < command.length &&
|
|
command[index + 1] !== "\n" &&
|
|
command[index + 1] !== "\r"
|
|
) {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === "<" || character === ">" || character === "(" || character === ")") {
|
|
unsupportedControl = true;
|
|
token += character;
|
|
continue;
|
|
}
|
|
if (/\s/u.test(character)) {
|
|
pushToken();
|
|
if (character === "\n" || character === "\r") pushSegment();
|
|
continue;
|
|
}
|
|
if (character === ";" || character === "|" || character === "&") {
|
|
pushSegment();
|
|
if (command[index + 1] === character) index += 1;
|
|
continue;
|
|
}
|
|
token += character;
|
|
}
|
|
if (quote || escaping) return null;
|
|
pushToken();
|
|
return Object.freeze({
|
|
segments: Object.freeze(
|
|
segments
|
|
.filter((segment) => segment.tokens.length > 0)
|
|
.map((segment) => Object.freeze({
|
|
tokens: Object.freeze(segment.tokens),
|
|
expansionTokens: Object.freeze(segment.expansionTokens),
|
|
})),
|
|
),
|
|
unsupportedControl,
|
|
});
|
|
}
|