liquibase-0001: DependencyGraph.recursiveSizeDepth diamond O(2^D) + evaluatedNodes O(N²); count 649→650
This commit is contained in:
parent
78c77fc5fa
commit
13b449d431
2 changed files with 114 additions and 1 deletions
|
|
@ -590,5 +590,6 @@
|
|||
"weld-0003": "UNDF-2026-000000554",
|
||||
"go-0002": "UNDF-2026-000000562",
|
||||
"nestjs-0003": "UNDF-2026-000000573",
|
||||
"poetry-0001": "UNDF-2026-000000575"
|
||||
"poetry-0001": "UNDF-2026-000000575",
|
||||
"liquibase-0001": "UNDF-2026-000000578"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
# UNDF: UNDF-2026-000000578
|
||||
# UNDF: (pending)
|
||||
# liquibase-0001: DependencyUtil.DependencyGraph.recursiveSizeDepth — O(2^D) diamond re-traversal + O(N²) evaluatedNodes list scan
|
||||
|
||||
## CWE-407 — Algorithmic Complexity: O(2^D) recursive diamond re-traversal; O(N²) list scan in evaluated-node guard
|
||||
|
||||
| Field | Value |
|
||||
|--------------|-------|
|
||||
| ID | liquibase-0001 |
|
||||
| Severity | MEDIUM |
|
||||
| Ecosystem | liquibase |
|
||||
| Package | liquibase-standard |
|
||||
| File | `liquibase-standard/src/main/java/liquibase/util/DependencyUtil.java` |
|
||||
| Lines | 132–155 |
|
||||
| Complexity | O(2^D) re-traversal on diamond dependency graphs; O(N) guard scan |
|
||||
| Hot path | Called during `computeDependencies()` at changelog load time |
|
||||
|
||||
## Defect
|
||||
|
||||
`DependencyGraph.recursiveSizeDepth(GraphNode<T> node, int safetyCounter)` recursively
|
||||
computes the total number of pending (not yet evaluated) nodes reachable from a starting
|
||||
node. It uses `isAlreadyEvaluated(node)` as an early-exit guard, which calls
|
||||
`evaluatedNodes.contains(node)` — an **O(N) linear scan** on an `ArrayList<GraphNode<T>>`.
|
||||
|
||||
On a diamond-shaped dependency graph (node A depends on B and C; both B and C depend on D),
|
||||
D is NOT in `evaluatedNodes` during the estimation phase, so the guard does not fire for D.
|
||||
The traversal visits D once via B and once via C — **2^D visits** at depth D.
|
||||
|
||||
```java
|
||||
// liquibase-standard/src/main/java/liquibase/util/DependencyUtil.java:132-154 (DEFECT)
|
||||
private final List<GraphNode<T>> evaluatedNodes = new ArrayList<>(); // O(N) contains()
|
||||
|
||||
private int recursiveSizeDepth(GraphNode<T> node, int safetyCounter) {
|
||||
if (safetyCounter > 1000) { return -1; }
|
||||
if (isAlreadyEvaluated(node)) { // O(N) scan — misses diamond nodes
|
||||
return 0;
|
||||
} else if (node.getGoingOutNodes() == null || node.getGoingOutNodes().isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
int sum = 0;
|
||||
safetyCounter++;
|
||||
for (GraphNode<T> n : node.getGoingOutNodes()) {
|
||||
int depth = recursiveSizeDepth(n, safetyCounter); // recurse — no current-path guard
|
||||
if (depth < 0) return -1;
|
||||
sum += depth;
|
||||
}
|
||||
return node.getGoingOutNodes().size() + sum;
|
||||
}
|
||||
|
||||
private boolean isAlreadyEvaluated(GraphNode<T> node) {
|
||||
return evaluatedNodes.contains(node); // O(N) linear scan
|
||||
}
|
||||
```
|
||||
|
||||
Two distinct defects:
|
||||
|
||||
1. **Diamond re-traversal:** `recursiveSizeDepth` has no guard for nodes currently being
|
||||
traversed in the recursion stack. On a diamond graph it visits shared nodes 2^D times.
|
||||
Also causes incorrect depth estimates (double-counts shared nodes).
|
||||
|
||||
2. **O(N) evaluatedNodes scan:** `evaluatedNodes` is an `ArrayList`. Each call to
|
||||
`isAlreadyEvaluated` or `areAlreadyEvaluated` is an O(N) scan.
|
||||
With E edges in the graph, total cost: O(E × N).
|
||||
|
||||
## Fix
|
||||
|
||||
Replace `evaluatedNodes: List<GraphNode<T>>` with a `Set<GraphNode<T>>` for O(1) membership,
|
||||
and add a `Set<GraphNode<T>> currentPath` parameter to `recursiveSizeDepth` to guard against
|
||||
diamond re-traversal:
|
||||
|
||||
```java
|
||||
// AFTER — O(N+E) total
|
||||
private final Set<GraphNode<T>> evaluatedNodes = new LinkedHashSet<>(); // O(1) contains()
|
||||
|
||||
private int recursiveSizeDepth(GraphNode<T> node, int safetyCounter,
|
||||
Set<GraphNode<T>> currentPath) {
|
||||
if (safetyCounter > 1000) { return -1; }
|
||||
if (evaluatedNodes.contains(node)) { return 0; } // O(1)
|
||||
if (!currentPath.add(node)) { return 0; } // diamond guard: O(1), prevents 2^D
|
||||
try {
|
||||
if (node.getGoingOutNodes() == null || node.getGoingOutNodes().isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
int sum = 0;
|
||||
for (GraphNode<T> n : node.getGoingOutNodes()) {
|
||||
int depth = recursiveSizeDepth(n, safetyCounter + 1, currentPath);
|
||||
if (depth < 0) return -1;
|
||||
sum += depth;
|
||||
}
|
||||
return node.getGoingOutNodes().size() + sum;
|
||||
} finally {
|
||||
currentPath.remove(node);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also change `areAlreadyEvaluated` to use the Set:
|
||||
```java
|
||||
private boolean areAlreadyEvaluated(List<GraphNode<T>> nodes) {
|
||||
return evaluatedNodes.containsAll(nodes); // O(K) with Set backing, vs O(K×N) with List
|
||||
}
|
||||
```
|
||||
|
||||
## Speedup
|
||||
|
||||
| Diamond depth (D) | Before (visits) | After (visits) | Speedup |
|
||||
|------------------|----------------|----------------|---------|
|
||||
| 10 | 1,023 | 10 | 102× |
|
||||
| 15 | 32,767 | 15 | 2,184× |
|
||||
| 20 | 1,048,575 | 20 | 52,428× |
|
||||
|
||||
Growth before: O(2^D). Growth after: O(D).
|
||||
Loading…
Add table
Add a link
Reference in a new issue