snyk-gradle-plugin
Version:
Snyk CLI Gradle plugin
493 lines • 22 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildGraph = buildGraph;
const tslib_1 = require("tslib");
const dep_graph_1 = require("@snyk/dep-graph");
const yocto_queue_1 = tslib_1.__importDefault(require("@common.js/yocto-queue"));
const coordinate_1 = require("./coordinate");
function precomputeChildrenMap(gradleGraph) {
const childrenMap = new Map();
for (const id of Object.keys(gradleGraph)) {
const node = gradleGraph[id];
if (node === null || node === void 0 ? void 0 : node.parentIds) {
for (const parentId of node.parentIds) {
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, []);
}
childrenMap.get(parentId).push(id);
}
}
}
return childrenMap;
}
function findChildren(parentId, childrenMap) {
const childrenIds = childrenMap.get(parentId) || [];
return childrenIds.map((id) => ({ id, parentId }));
}
async function buildGraph(gradleGraph, rootPkgName, projectVersion, verbose, sha1Map) {
const pkgManager = { name: 'gradle' };
const isEmptyGraph = !gradleGraph || Object.keys(gradleGraph).length === 0;
const depGraphBuilder = new dep_graph_1.DepGraphBuilder(pkgManager, {
name: rootPkgName,
version: projectVersion || '0.0.0',
});
if (isEmptyGraph) {
return depGraphBuilder.build();
}
const childrenMap = precomputeChildrenMap(gradleGraph);
if (verbose) {
return buildVerboseGraph(depGraphBuilder, gradleGraph, childrenMap, sha1Map);
}
const visitedMap = {};
const queue = new yocto_queue_1.default();
findChildren('root-node', childrenMap).forEach((item) => queue.enqueue(item));
// breadth first search
while (queue.size > 0) {
const item = queue.dequeue();
if (!item)
continue;
let { id, parentId } = item;
// take a copy as id maybe mutated below and we need this id when finding childing in GradleGraph
const gradleGraphId = `${id}`;
const node = gradleGraph[id];
if (!node)
continue;
let { name = 'unknown', version = 'unknown' } = node;
let pkgIdProvenance = undefined;
if (sha1Map) {
if (sha1Map[id]) {
id = sha1Map[id];
const coord = (0, coordinate_1.parseCoordinate)(id);
const newName = `${coord.groupId}:${coord.artifactId}`;
const newVersion = coord.version;
if (name !== newName || version !== newVersion) {
pkgIdProvenance = `${name}@${version}`; // record pkg id provenance if re coordinated
name = newName;
version = newVersion;
}
}
if (sha1Map[parentId]) {
parentId = sha1Map[parentId];
}
}
const visited = visitedMap[id];
if (visited) {
const prunedId = id + ':pruned';
depGraphBuilder.addPkgNode({ name, version }, prunedId, createNodeInfo(pkgIdProvenance, 'true'));
depGraphBuilder.connectDep(parentId, prunedId);
continue; // don't queue any more children
}
else {
depGraphBuilder.addPkgNode({ name, version }, id, createNodeInfo(pkgIdProvenance, undefined, {
hashes: node.hashes,
distributionUrl: node.distributionUrl,
}));
depGraphBuilder.connectDep(parentId, id);
visitedMap[id] = { name, version };
}
findChildren(gradleGraphId, childrenMap).forEach((item) => queue.enqueue(item));
}
return depGraphBuilder.build();
}
// The verbose graph used to be defined per path: whether an edge was drawn to a
// package or to a `:pruned` cycle placeholder depended on the ancestry of the
// route that reached it. Discovering that meant walking every route, which is
// O(paths) - on a reactor where packages are reachable many ways it takes
// minutes to hours, all of it after Gradle itself has finished.
//
// Deciding per edge instead needs two graph properties:
//
// * `u -> v` takes part in a cycle exactly when `v` can reach `u`, i.e. they
// share a strongly connected component;
// * the plain edge is drawn as well exactly when some route to `u` avoids
// `v`, i.e. `u` is still reachable from the root once `v` is removed.
//
// This is a deliberate change of definition, not a reproduction of the old one:
// see the note on `closesCycle` below.
function buildVerboseGraph(depGraphBuilder, gradleGraph, childrenMap, sha1Map) {
// sha1Map re-coordinates a package, and two ids Gradle reported separately
// can resolve onto one coordinate. The old walk keyed its visited set and its
// ancestry on the resolved id, so cycles were decided in resolved-id space -
// everything below therefore works in that space too, and never in the raw
// one. Analysing the raw graph instead makes a collapsed parent and child
// look like two packages and emits a package that depends on itself.
// A sha1Map entry re-coordinating some package onto the root's own id would
// otherwise claim the root's slot in the group map below, so `childrenOf`
// would enumerate that package's children instead of the root's and return a
// graph holding nothing but the root. The old walk asked DepGraphBuilder to
// overwrite the root and got an error; keeping the raw id loses nothing.
const resolvedIdOf = (id) => {
const resolvedId = (sha1Map && sha1Map[id]) || id;
if (resolvedId === 'root-node' && id !== 'root-node')
return id;
return resolvedId;
};
const rawIdsByResolvedId = groupRawIdsByResolvedId(gradleGraph, resolvedIdOf);
// The root is the one id that need not be a key of gradleGraph, so it is the
// one id without a group of its own.
const childrenOf = (resolvedId) => {
const rawIds = resolvedId === 'root-node'
? ['root-node']
: rawIdsByResolvedId.get(resolvedId);
const children = [];
for (const rawId of rawIds) {
for (const child of childrenMap.get(rawId) || []) {
if (gradleGraph[child])
children.push(resolvedIdOf(child));
}
}
return children;
};
// init.gradle only ever adds a node together with an edge from the root or
// from a node it has already added, so every package in gradleGraph is
// reachable from the root and needs no reachability pass to find it.
const packages = [...rawIdsByResolvedId.keys()].filter((id) => id !== 'root-node');
const componentOf = findStronglyConnectedComponents(packages, childrenOf);
const dominates = buildCycleDominanceTest(packages, childrenOf, componentOf);
// `to` has to be able to reach `from` for the edge to sit on a cycle, and
// `to` has to be reachable without `from` for it to be able to come first on
// any route - otherwise `to` can only ever be seen after `from` and the edge
// is an ordinary one. Both conditions are necessary but not jointly
// sufficient: deciding it exactly asks whether a route to `from` passes
// through `to`, which needs the two halves to be vertex-disjoint and is the
// NP-hard two-disjoint-paths problem. So this is a deliberate
// over-approximation - it can mark an extra edge as cyclic, and never drops
// a package or a real dependency edge.
const closesCycle = (from, to) => from === to ||
(componentOf.get(from) === componentOf.get(to) && !dominates(from, to));
// Whether some route from the root reaches `from` without passing through
// `to`: if one does, `from -> to` can be walked with `to` not yet an
// ancestor, so the plain edge is drawn alongside the cycle placeholder.
const canReachSourceWithoutTarget = (from, to) => from !== to && !dominates(to, from);
const coordinatesOf = createCoordinatesLookup(gradleGraph, rawIdsByResolvedId, sha1Map);
const added = new Set();
// The first route to reach a package cannot already contain it, so every
// package gets a node of its own.
for (const id of packages) {
const coordinates = coordinatesOf(id);
added.add(id);
depGraphBuilder.addPkgNode({ name: coordinates.name, version: coordinates.version }, id, createNodeInfo(coordinates.pkgIdProvenance, undefined, {
hashes: coordinates.hashes,
distributionUrl: coordinates.distributionUrl,
}));
}
// The root is never its own ancestor, so its own edges are always plain.
for (const child of childrenOf('root-node')) {
depGraphBuilder.connectDep(resolvedIdOf('root-node'), child);
}
for (const from of packages) {
for (const to of childrenOf(from)) {
if (closesCycle(from, to)) {
const prunedId = to + ':pruned';
if (!added.has(prunedId)) {
added.add(prunedId);
const coordinates = coordinatesOf(to);
depGraphBuilder.addPkgNode({ name: coordinates.name, version: coordinates.version }, prunedId, createNodeInfo(coordinates.pkgIdProvenance, 'cyclic'));
}
depGraphBuilder.connectDep(from, prunedId);
if (!canReachSourceWithoutTarget(from, to))
continue;
}
depGraphBuilder.connectDep(from, to);
}
}
return depGraphBuilder.build();
}
// Raw ids that resolve to one coordinate have to agree on the metadata they
// contribute. The old walk took whichever the traversal happened to reach
// first; putting the lowest raw id first instead is arbitrary in the same way
// but stable, so the emitted graph does not depend on traversal order.
function groupRawIdsByResolvedId(gradleGraph, resolvedIdOf) {
const rawIdsByResolvedId = new Map();
for (const rawId of Object.keys(gradleGraph).sort()) {
const resolvedId = resolvedIdOf(rawId);
const rawIds = rawIdsByResolvedId.get(resolvedId);
if (rawIds)
rawIds.push(rawId);
else
rawIdsByResolvedId.set(resolvedId, [rawId]);
}
return rawIdsByResolvedId;
}
// A package's coordinates are asked for once for its own node and again for
// each cycle placeholder pointing at it, so they are worked out once and kept.
function createCoordinatesLookup(gradleGraph, rawIdsByResolvedId, sha1Map) {
const cache = new Map();
return (resolvedId) => {
const cached = cache.get(resolvedId);
if (cached)
return cached;
const rawId = rawIdsByResolvedId.get(resolvedId)[0];
const node = gradleGraph[rawId];
// Destructuring, not `??`: the default has to fire on undefined alone, as
// it does on the non-verbose path above. `??` would also swallow a null
// name, so one plugin would report two different component identities for
// the same Gradle output depending on --print-graph.
let { name = 'unknown', version = 'unknown' } = node;
let pkgIdProvenance = undefined;
// Compare rather than just test for presence: when the guard in
// `resolvedIdOf` has declined a sha1Map entry, the resolved id is the raw
// one and re-coordinating against it would parse a sha1 hash as a Maven
// coordinate.
if (sha1Map && sha1Map[rawId] === resolvedId) {
const coord = (0, coordinate_1.parseCoordinate)(resolvedId);
const newName = `${coord.groupId}:${coord.artifactId}`;
const newVersion = coord.version;
if (name !== newName || version !== newVersion) {
pkgIdProvenance = `${name}@${version}`; // record pkg id provenance if re coordinated
name = newName;
version = newVersion;
}
}
const coordinates = {
name,
version,
pkgIdProvenance,
hashes: node.hashes,
distributionUrl: node.distributionUrl,
};
cache.set(resolvedId, coordinates);
return coordinates;
};
}
// Dominance is only ever asked about two packages in the same strongly
// connected component, and a route from the root that enters a component
// cannot leave it and come back. So whether one member dominates another
// depends only on the component itself and on where routes enter it, and each
// cyclic component gets a dominator tree of its own. Packages outside any
// cycle - the bulk of a real dependency graph - cost nothing here.
//
// Keeping the trees small matters because Cooper, Harvey and Kennedy's
// algorithm is quadratic in the worst case: a single tree over a long chain
// whose every link also depends on one shared library climbs ever-longer
// dominator chains, and took seconds at a few thousand packages.
function buildCycleDominanceTest(packages, childrenOf, componentOf) {
const componentSizes = new Map();
for (const component of componentOf.values()) {
componentSizes.set(component, (componentSizes.get(component) || 0) + 1);
}
const isCyclic = (component) => component !== undefined && componentSizes.get(component) > 1;
// A member is an entry when an edge reaches it from outside its component;
// the root is in no component, so its children always count.
const entriesByComponent = new Map();
for (const from of ['root-node', ...packages]) {
for (const to of childrenOf(from)) {
const component = componentOf.get(to);
if (!isCyclic(component) || componentOf.get(from) === component)
continue;
const entries = entriesByComponent.get(component);
if (entries)
entries.push(to);
else
entriesByComponent.set(component, [to]);
}
}
const dominanceByComponent = new Map();
for (const [component, entries] of entriesByComponent) {
const membersOnly = (id) => childrenOf(id).filter((child) => componentOf.get(child) === component);
dominanceByComponent.set(component, buildDominanceTest(entries, membersOnly));
}
return (dominator, id) => {
const test = dominanceByComponent.get(componentOf.get(id));
return test ? test(dominator, id) : false;
};
}
// A node id the graph cannot contain, so the dominator tree can have an entry
// of its own that leads to every place routes come in from outside.
const DOMINANCE_ENTRY = '\u0000dominance-entry';
// `to` is reachable from the entries without `from` exactly when `from` does
// not dominate `to`, so one dominator tree answers every such question in
// constant time.
function buildDominanceTest(entries, successorsOf) {
const graph = prepareDominanceGraph(DOMINANCE_ENTRY, (id) => id === DOMINANCE_ENTRY ? entries : successorsOf(id));
const immediateDominator = computeImmediateDominators(DOMINANCE_ENTRY, graph);
return createDominanceLookup(DOMINANCE_ENTRY, immediateDominator);
}
function prepareDominanceGraph(entry, successorsOf) {
// A node is recorded in postorder by its leaving frame, which is pushed
// beneath its children so that it pops once they have all been walked.
const postorder = [];
const seen = new Set();
const stack = [{ id: entry }];
while (stack.length > 0) {
const { id, leaving } = stack.pop();
if (leaving) {
postorder.push(id);
continue;
}
if (seen.has(id))
continue;
seen.add(id);
stack.push({ id, leaving: true });
const successors = successorsOf(id);
for (let i = successors.length - 1; i >= 0; i--) {
if (!seen.has(successors[i]))
stack.push({ id: successors[i] });
}
}
const order = postorder.reverse();
const rank = new Map();
order.forEach((id, position) => rank.set(id, position));
const predecessors = new Map();
for (const id of order) {
for (const successor of successorsOf(id)) {
const known = predecessors.get(successor);
if (known)
known.push(id);
else
predecessors.set(successor, [id]);
}
}
return { order, rank, predecessors };
}
// Cooper, Harvey and Kennedy's iterative formulation: refine each node's
// immediate dominator from its predecessors' until nothing changes.
function computeImmediateDominators(entry, { order, rank, predecessors }) {
const immediateDominator = new Map([[entry, entry]]);
// Walks both nodes up the dominator tree built so far until they meet.
const nearestCommonDominator = (left, right) => {
let a = left;
let b = right;
while (a !== b) {
while (rank.get(a) > rank.get(b))
a = immediateDominator.get(a);
while (rank.get(b) > rank.get(a))
b = immediateDominator.get(b);
}
return a;
};
let settled = false;
while (!settled) {
settled = true;
for (const id of order) {
if (id === entry)
continue;
let candidate;
for (const predecessor of predecessors.get(id) || []) {
if (!immediateDominator.has(predecessor))
continue;
candidate =
candidate === undefined
? predecessor
: nearestCommonDominator(predecessor, candidate);
}
if (candidate !== undefined && immediateDominator.get(id) !== candidate) {
immediateDominator.set(id, candidate);
settled = false;
}
}
}
return immediateDominator;
}
// Entry and exit stamps over the dominator tree turn dominance into a range
// check: one node dominates another when its interval encloses it.
function createDominanceLookup(entry, immediateDominator) {
const treeChildren = new Map();
for (const [id, parent] of immediateDominator) {
if (id === entry)
continue;
const known = treeChildren.get(parent);
if (known)
known.push(id);
else
treeChildren.set(parent, [id]);
}
const entered = new Map();
const exited = new Map();
let clock = 0;
const stack = [{ id: entry }];
while (stack.length > 0) {
const { id, leaving } = stack.pop();
if (leaving) {
exited.set(id, clock++);
continue;
}
entered.set(id, clock++);
stack.push({ id, leaving: true });
for (const child of treeChildren.get(id) || [])
stack.push({ id: child });
}
return (dominator, id) => {
const from = entered.get(dominator);
const to = entered.get(id);
if (from === undefined || to === undefined)
return false;
return (from <= to &&
exited.get(id) <= exited.get(dominator));
};
}
// Tarjan's algorithm, driven by an explicit stack: a recursive implementation
// overflows the call stack on the deep dependency chains this has to cope with.
function findStronglyConnectedComponents(nodeIds, childrenOf) {
const index = new Map();
const lowLink = new Map();
const onStack = new Set();
const pending = [];
const componentOf = new Map();
let nextIndex = 0;
let nextComponent = 0;
const open = (id) => {
index.set(id, nextIndex);
lowLink.set(id, nextIndex);
nextIndex++;
pending.push(id);
onStack.add(id);
};
for (const start of nodeIds) {
if (index.has(start))
continue;
open(start);
const work = [{ id: start, children: childrenOf(start), next: 0 }];
while (work.length > 0) {
const frame = work[work.length - 1];
if (frame.next < frame.children.length) {
const child = frame.children[frame.next++];
if (!index.has(child)) {
open(child);
work.push({ id: child, children: childrenOf(child), next: 0 });
}
else if (onStack.has(child)) {
lowLink.set(frame.id, Math.min(lowLink.get(frame.id), index.get(child)));
}
continue;
}
work.pop();
if (work.length > 0) {
const caller = work[work.length - 1];
lowLink.set(caller.id, Math.min(lowLink.get(caller.id), lowLink.get(frame.id)));
}
if (lowLink.get(frame.id) === index.get(frame.id)) {
const component = nextComponent++;
let member;
do {
member = pending.pop();
onStack.delete(member);
componentOf.set(member, component);
} while (member !== frame.id);
}
}
}
return componentOf;
}
function createNodeInfo(pkgIdProvenance, pruned, componentMetadata) {
const labels = {};
if (pruned)
labels.pruned = pruned;
if (pkgIdProvenance)
labels.pkgIdProvenance = pkgIdProvenance;
// Component-metadata labels use the shared cross-ecosystem vocabulary
// (hash:<alg>, distribution:url). Present only when init.gradle emitted them.
if (componentMetadata) {
const { hashes, distributionUrl } = componentMetadata;
if (hashes) {
for (const [alg, value] of Object.entries(hashes)) {
if (value)
labels[`hash:${alg}`] = value;
}
}
if (distributionUrl)
labels['distribution:url'] = distributionUrl;
}
return Object.keys(labels).length ? { labels } : undefined;
}
//# sourceMappingURL=graph.js.map