UNPKG

snyk-gradle-plugin

Version:
493 lines • 22 kB
"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