Skip to content

Commit 85d5cd1

Browse files
committed
fix(manifest): materialize the healthy Maven dependencies of a partial graph
Maven throws on a collection error before it fetches any artifact, so one dependency whose POM or version range failed left every other dependency of the module without its jar. The facts extension now runs the artifact step itself on the partial graph, leaving the failed dependencies out, and reports each jar it cannot fetch under its full coordinate with an unfetched record. Maven 4's batched failures are unwrapped to their real cause.
1 parent 500ea3f commit 85d5cd1

5 files changed

Lines changed: 135 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1212
### Fixed
1313
- With `--dynamic-sbom-inference`, an sbt build that fails before writing its results is reported as failed instead of reusing another build's results.
1414
- JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code.
15+
- One unresolvable Maven dependency no longer strips the jar paths from every other dependency of its build, which made reachability analysis call their vulnerabilities unreachable. Jars that cannot be fetched are reported with their real cause, Maven 4 included.
1516

1617
## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22
1718

‎src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.apache.maven.execution.MavenSession;
66
import org.apache.maven.project.ProjectDependenciesResolver;
77
import org.apache.maven.rtinfo.RuntimeInformation;
8+
import org.eclipse.aether.RepositorySystem;
89
import org.slf4j.Logger;
910
import org.slf4j.LoggerFactory;
1011
import tech.coana.socket.SocketFactsRecordsEngine;
@@ -28,12 +29,16 @@ public class CoanaFactsLifecycleParticipant extends AbstractMavenLifecyclePartic
2829
private static final Logger LOG = LoggerFactory.getLogger("coana");
2930

3031
private final ProjectDependenciesResolver dependenciesResolver;
32+
private final RepositorySystem repositorySystem;
3133
private final RuntimeInformation runtimeInformation;
3234

3335
@Inject
3436
public CoanaFactsLifecycleParticipant(
35-
ProjectDependenciesResolver dependenciesResolver, RuntimeInformation runtimeInformation) {
37+
ProjectDependenciesResolver dependenciesResolver,
38+
RepositorySystem repositorySystem,
39+
RuntimeInformation runtimeInformation) {
3640
this.dependenciesResolver = dependenciesResolver;
41+
this.repositorySystem = repositorySystem;
3742
this.runtimeInformation = runtimeInformation;
3843
}
3944

@@ -55,7 +60,7 @@ public void afterSessionEnd(MavenSession session) throws MavenExecutionException
5560
opts.excludePaths = opt(session, "socket.excludePaths");
5661
File rootDir = new File(session.getExecutionRootDirectory());
5762
try {
58-
new SocketFactsRecordsEngine(dependenciesResolver, runtimeInformation.getMavenVersion(), LOG)
63+
new SocketFactsRecordsEngine(dependenciesResolver, repositorySystem, runtimeInformation.getMavenVersion(), LOG)
5964
.run(session, session.getProjects(), rootDir, opts);
6065
} catch (IOException exception) {
6166
throw new MavenExecutionException("Cannot write socket facts records", exception);

‎src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java‎

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
import org.apache.maven.project.DependencyResolutionResult;
99
import org.apache.maven.project.MavenProject;
1010
import org.apache.maven.project.ProjectDependenciesResolver;
11+
import org.eclipse.aether.RepositorySystem;
1112
import org.eclipse.aether.artifact.Artifact;
1213
import org.eclipse.aether.graph.Dependency;
1314
import org.eclipse.aether.graph.DependencyFilter;
1415
import org.eclipse.aether.graph.DependencyNode;
1516
import org.eclipse.aether.resolution.ArtifactDescriptorException;
17+
import org.eclipse.aether.resolution.ArtifactResult;
18+
import org.eclipse.aether.resolution.DependencyRequest;
1619
import org.eclipse.aether.resolution.VersionRangeResolutionException;
1720
import org.slf4j.Logger;
1821

@@ -72,12 +75,15 @@ public static final class Options {
7275
private static final String NODE_DATA_CONFLICT_WINNER = "conflict.winner";
7376

7477
private final ProjectDependenciesResolver dependenciesResolver;
78+
private final RepositorySystem repositorySystem;
7579
private final String mavenVersion;
7680
private final Logger log;
7781

7882
public SocketFactsRecordsEngine(
79-
ProjectDependenciesResolver dependenciesResolver, String mavenVersion, Logger log) {
83+
ProjectDependenciesResolver dependenciesResolver, RepositorySystem repositorySystem, String mavenVersion,
84+
Logger log) {
8085
this.dependenciesResolver = dependenciesResolver;
86+
this.repositorySystem = repositorySystem;
8187
this.mavenVersion = mavenVersion;
8288
this.log = log;
8389
}
@@ -166,10 +172,11 @@ private void collectModule(
166172
String moduleCoord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion();
167173
DependencyResolutionResult result;
168174
DependencyResolutionException thrown = null;
175+
DependencyFilter resolutionFilter = materializationFilter(opts, passingScopes, reactorGavs, populateGavs);
169176
try {
170177
DependencyResolutionRequest request =
171178
new DefaultDependencyResolutionRequest(module, session.getRepositorySession());
172-
request.setResolutionFilter(materializationFilter(opts, passingScopes, reactorGavs, populateGavs));
179+
request.setResolutionFilter(resolutionFilter);
173180
result = dependenciesResolver.resolve(request);
174181
} catch (DependencyResolutionException e) {
175182
// Maven attaches the partial result — graph plus per-dependency errors — to the exception, so
@@ -205,13 +212,16 @@ private void collectModule(
205212
log.warn("[socket-facts] could not build dependency graph for " + moduleCoord + ": " + rootMessage(e));
206213
reported = true;
207214
}
215+
// Maven throws on a collection error before it fetches any artifact, which would leave every
216+
// healthy node of the partial graph without its file.
217+
Set<String> unfetched = new HashSet<>();
218+
if (!result.getCollectionErrors().isEmpty() && graph != null && opts.withFiles) {
219+
materialize(session, graph, resolutionFilter, unresolved, moduleCoord, failures, unfetched);
220+
}
208221
for (Dependency dep : result.getUnresolvedDependencies()) {
209222
Artifact artifact = dep.getArtifact();
210-
if (artifact == null) continue;
211-
List<Exception> errors = result.getResolutionErrors(dep);
212-
failures.add(new Failure(
213-
gav(artifact), rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep), moduleCoord));
214-
log.debug("[socket-facts] could not materialize " + artifact + " (" + scopeOf(dep) + ")");
223+
if (artifact == null || unresolved.contains(gav(artifact))) continue;
224+
reportUnfetched(dep, result.getResolutionErrors(dep), moduleCoord, failures, unfetched);
215225
reported = true;
216226
}
217227
// Fail closed: a throw whose result named nothing must still surface, or an unresolved dependency
@@ -233,6 +243,10 @@ private void collectModule(
233243
if (isProd(scopeOf(child.getDependency()))) directProdIds.add(id);
234244
}
235245
}
246+
for (String id : unfetched) {
247+
Node node = nodes.get(id);
248+
if (node != null) node.unfetched = true;
249+
}
236250
}
237251

238252
/**
@@ -275,6 +289,47 @@ public boolean accept(DependencyNode node, List<DependencyNode> parents) {
275289
};
276290
}
277291

292+
/**
293+
* The artifact step Maven skipped, run on the partial graph the way the resolver's own
294+
* {@code resolveDependencies} continues past a collection failure. The failed dependencies are left
295+
* out: they are never emitted, and fetching them again would only repeat the failure.
296+
*/
297+
private void materialize(
298+
MavenSession session,
299+
DependencyNode graph,
300+
final DependencyFilter resolutionFilter,
301+
final Set<String> unresolved,
302+
String moduleCoord,
303+
Set<Failure> failures,
304+
Set<String> unfetched) {
305+
DependencyFilter healthy = new DependencyFilter() {
306+
@Override
307+
public boolean accept(DependencyNode node, List<DependencyNode> parents) {
308+
return resolutionFilter.accept(node, parents) && !unresolved.contains(gav(node.getArtifact()));
309+
}
310+
};
311+
List<ArtifactResult> results;
312+
try {
313+
results = repositorySystem
314+
.resolveDependencies(session.getRepositorySession(), new DependencyRequest(graph, healthy))
315+
.getArtifactResults();
316+
} catch (org.eclipse.aether.resolution.DependencyResolutionException e) {
317+
results = e.getResult().getArtifactResults();
318+
}
319+
for (ArtifactResult ar : results) {
320+
if (ar.isResolved()) continue;
321+
reportUnfetched(ar.getRequest().getDependencyNode().getDependency(), ar.getExceptions(), moduleCoord, failures, unfetched);
322+
}
323+
}
324+
325+
private void reportUnfetched(
326+
Dependency dep, List<Exception> errors, String moduleCoord, Set<Failure> failures, Set<String> unfetched) {
327+
String id = externalId(dep.getArtifact());
328+
failures.add(new Failure(id, rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep), moduleCoord));
329+
unfetched.add(id);
330+
log.debug("[socket-facts] could not materialize " + dep.getArtifact() + " (" + scopeOf(dep) + ")");
331+
}
332+
278333
private String visit(
279334
DependencyNode dn,
280335
Set<String> passingScopes,
@@ -295,16 +350,12 @@ private String visit(
295350
String gav = gav(artifact);
296351
if (unresolved.contains(gav)) return null;
297352
boolean internal = reactorGavs.contains(gav);
298-
// Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the
299-
// assembler and the Gradle/SBT scripts already emit.
300353
String type = artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension());
301354
String classifier = artifact.getClassifier();
302-
// Base version: a resolved remote snapshot's `version` is the timestamped build, which would
303-
// put a coordinate in the records that no manifest ever names.
304355
String version = artifact.getBaseVersion();
305356
String id = internal
306357
? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), version)
307-
: SocketSupport.coordId(artifact.getGroupId(), artifact.getArtifactId(), type, classifier, version);
358+
: externalId(artifact);
308359

309360
// One walk per node per module traversal: a shared subtree reached via another edge is already
310361
// fully recorded (node, children, resolved file), so hand back the id without re-descending.
@@ -329,6 +380,17 @@ private String visit(
329380
return id;
330381
}
331382

383+
private static String externalId(Artifact artifact) {
384+
// Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the
385+
// assembler and the Gradle/SBT scripts already emit. Base version: a resolved remote snapshot's
386+
// `version` is the timestamped build, which would put a coordinate in the records that no
387+
// manifest ever names.
388+
return SocketSupport.coordId(
389+
artifact.getGroupId(), artifact.getArtifactId(),
390+
artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension()), artifact.getClassifier(),
391+
artifact.getBaseVersion());
392+
}
393+
332394
private static String gav(Artifact artifact) {
333395
return artifact.getGroupId() + ":" + artifact.getArtifactId() + ":" + artifact.getBaseVersion();
334396
}
@@ -405,6 +467,7 @@ private int emitRoot(
405467
if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child);
406468
}
407469
for (String f : n.files) rec(lines, "file", rootId, n.id, f);
470+
if (n.unfetched) rec(lines, "unfetched", rootId, n.id);
408471
}
409472
return rootIdx + 1;
410473
}
@@ -519,7 +582,11 @@ private static String rootMessage(Throwable t) {
519582
int guard = 0;
520583
while (cur != null && guard++ < 12) {
521584
if (cur.getMessage() != null) msg = cur.getMessage();
522-
cur = cur.getCause();
585+
Throwable next = cur.getCause();
586+
// Maven 4 batches requests: BatchRequestException carries each failed request as a suppressed
587+
// exception, not as its cause.
588+
if (next == null && cur.getSuppressed().length > 0) next = cur.getSuppressed()[0];
589+
cur = next;
523590
}
524591
return (msg != null ? msg : "unknown resolution failure").trim();
525592
}
@@ -623,6 +690,7 @@ private static final class Node {
623690
final String project;
624691
final TreeSet<String> children = new TreeSet<>();
625692
final TreeSet<String> files = new TreeSet<>();
693+
boolean unfetched;
626694

627695
Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) {
628696
this.id = id;

‎src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@
99
<maven.compiler.target>8</maven.compiler.target>
1010
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
1111
</properties>
12-
<!-- ok resolves. The rest fail while Maven collects the graph: lib's and testlib's POMs name a parent that cannot be
13-
fetched, which leaves them in the graph with their scope; no version of ranged exists, which
14-
leaves it out of the graph. -->
12+
<!-- ok resolves. nojar's POM resolves but its jar is absent. The rest fail while Maven collects the graph: lib's
13+
and testlib's POMs name a parent that cannot be fetched, which leaves them in the graph with their scope; no
14+
version of ranged exists, which leaves it out of the graph. -->
1515
<dependencies>
1616
<dependency>
1717
<groupId>demo.graphfail</groupId>
1818
<artifactId>ok</artifactId>
1919
<version>1.0</version>
2020
</dependency>
21+
<dependency>
22+
<groupId>demo.graphfail</groupId>
23+
<artifactId>nojar</artifactId>
24+
<version>1.0</version>
25+
</dependency>
2126
<dependency>
2227
<groupId>demo.graphfail</groupId>
2328
<artifactId>lib</artifactId>

‎src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
# dependency, in the module it was collected for, and never emitted as a node. Maven keeps a
44
# dependency whose POM failed in the graph (Maven 3.2's resolver leaves it out instead), so it fails
55
# in its own scope, and an excluded scope drops it; a dependency the graph lacks (an unsatisfiable
6-
# range) has no scope. The module itself must never be reported as the failure.
6+
# range) has no scope. The module itself must never be reported as the failure. Under --with-files,
7+
# every healthy dependency must still get its file, and a jar that cannot be fetched is reported under
8+
# its full coordinate, with the cause rather than a wrapper.
79
#
810
# Runs offline: the POMs are seeded straight into the local repository, so their missing parent and
911
# the absent versions of ranged can only fail to resolve.
@@ -34,15 +36,18 @@ for lib in lib testlib; do
3436
</project>
3537
POM
3638
done
37-
mkdir -p "$M2/demo/graphfail/ok/1.0"
38-
cat > "$M2/demo/graphfail/ok/1.0/ok-1.0.pom" <<'POM'
39+
for lib in ok nojar; do
40+
mkdir -p "$M2/demo/graphfail/$lib/1.0"
41+
cat > "$M2/demo/graphfail/$lib/1.0/$lib-1.0.pom" <<POM
3942
<project xmlns="http://maven.apache.org/POM/4.0.0">
4043
<modelVersion>4.0.0</modelVersion>
4144
<groupId>demo.graphfail</groupId>
42-
<artifactId>ok</artifactId>
45+
<artifactId>$lib</artifactId>
4346
<version>1.0</version>
4447
</project>
4548
POM
49+
done
50+
( cd "$M2/demo/graphfail/ok/1.0" && mkdir -p empty && jar cf ok-1.0.jar -C empty . && rm -r empty )
4651

4752
run() {
4853
rm -f "$RECORDS"
@@ -100,6 +105,32 @@ print(f"PASS (graph failure): {expected}")
100105
PY
101106
}
102107

108+
check_files() {
109+
python3 - "$RECORDS" <<'PY'
110+
import sys
111+
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
112+
files = {r[2] for r in rows if r[0] == 'file'}
113+
failures = {(r[1], r[3]) for r in rows if r[0] == 'failure'}
114+
details = {r[2] for r in rows if r[0] == 'failure' and r[1] == 'demo.graphfail:nojar:jar:1.0'}
115+
unfetched = {r[2] for r in rows if r[0] == 'unfetched'}
116+
errors = []
117+
if 'demo.graphfail:ok:jar:1.0' not in files:
118+
errors.append(f"ok resolved, so its jar must be materialized despite the collection errors; files={sorted(files)}")
119+
if ('demo.graphfail:nojar:jar:1.0', 'compile') not in failures:
120+
errors.append(f"nojar's jar cannot be fetched, so it must be reported; failures={sorted(failures)}")
121+
if any('One or more requests failed' in d for d in details):
122+
errors.append(f"nojar's failure must give the cause, not the batch wrapper; details={sorted(details)}")
123+
if unfetched != {'demo.graphfail:nojar:jar:1.0'}:
124+
errors.append(f"only nojar's jar is unfetched; unfetched={sorted(unfetched)}")
125+
if errors:
126+
print("FAIL:")
127+
for e in errors:
128+
print(" -", e)
129+
sys.exit(1)
130+
print("PASS (graph failure, with files)")
131+
PY
132+
}
133+
103134
VERSION="$("$MVN" -v 2>/dev/null | head -1)"
104135
echo "+ $VERSION (graph failure)"
105136
case "$VERSION" in
@@ -110,3 +141,6 @@ run
110141
check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": "test", "demo.graphfail:ranged:[1.0,2.0)": ""}'
111142
run -Dsocket.excludeConfigs=test
112143
check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": null, "demo.graphfail:ranged:[1.0,2.0)": ""}'
144+
run -Dsocket.withFiles=true
145+
check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": "test", "demo.graphfail:ranged:[1.0,2.0)": ""}'
146+
check_files

0 commit comments

Comments
 (0)