Skip to content

Commit ef42442

Browse files
ruromeroclaude
andcommitted
refactor: use pip install --dry-run --report for pyproject.toml dependency resolution
Replace the venv + pip install/freeze/show chain with a single pip install --dry-run --ignore-installed --report command that outputs the full dependency tree as JSON. This eliminates temporary file creation, virtual environment setup, and multiple subprocess calls. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 473aeb5 commit ef42442

5 files changed

Lines changed: 723 additions & 27 deletions

File tree

src/main/java/io/github/guacsec/trustifyda/providers/PythonProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ private void printDependenciesTree(List<Map<String, Object>> dependencies)
174174
}
175175
}
176176

177-
private void handleIgnoredDependencies(String manifestContent, Sbom sbom) {
177+
protected void handleIgnoredDependencies(String manifestContent, Sbom sbom) {
178178
Set<PackageURL> ignoredDeps = getIgnoredDependencies(manifestContent);
179179
Set<String> ignoredDepsVersions =
180180
ignoredDeps.stream()

src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java

Lines changed: 245 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,30 @@
1616
*/
1717
package io.github.guacsec.trustifyda.providers;
1818

19+
import com.fasterxml.jackson.databind.JsonNode;
1920
import com.github.packageurl.PackageURL;
21+
import io.github.guacsec.trustifyda.Api;
2022
import io.github.guacsec.trustifyda.license.LicenseUtils;
2123
import io.github.guacsec.trustifyda.logging.LoggersFactory;
24+
import io.github.guacsec.trustifyda.sbom.Sbom;
25+
import io.github.guacsec.trustifyda.sbom.SbomFactory;
26+
import io.github.guacsec.trustifyda.tools.Operations;
27+
import io.github.guacsec.trustifyda.utils.Environment;
2228
import io.github.guacsec.trustifyda.utils.PythonControllerBase;
2329
import java.io.IOException;
30+
import java.nio.charset.StandardCharsets;
2431
import java.nio.file.Files;
2532
import java.nio.file.Path;
2633
import java.util.ArrayList;
34+
import java.util.Base64;
35+
import java.util.HashMap;
2736
import java.util.HashSet;
2837
import java.util.List;
38+
import java.util.Map;
2939
import java.util.Set;
3040
import java.util.logging.Logger;
41+
import java.util.regex.Matcher;
42+
import java.util.regex.Pattern;
3143
import java.util.stream.Collectors;
3244
import org.tomlj.Toml;
3345
import org.tomlj.TomlArray;
@@ -39,6 +51,12 @@ public final class PythonPyprojectProvider extends PythonProvider {
3951
private static final Logger log =
4052
LoggersFactory.getLogger(PythonPyprojectProvider.class.getName());
4153

54+
static final String PROP_TRUSTIFY_DA_PIP_REPORT = "TRUSTIFY_DA_PIP_REPORT";
55+
56+
private static final Pattern DEP_NAME_PATTERN =
57+
Pattern.compile("^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)");
58+
private static final Pattern EXTRA_MARKER_PATTERN = Pattern.compile(";\\s*.*extra\\s*==");
59+
4260
private Set<String> collectedIgnoredDeps;
4361
private TomlParseResult cachedToml;
4462

@@ -47,18 +65,214 @@ public PythonPyprojectProvider(Path manifest) {
4765
}
4866

4967
@Override
50-
protected Path getRequirementsPath() throws IOException {
51-
List<String> depStrings = parseDependencyStrings();
52-
Path tmpDir = Files.createTempDirectory("trustify_da_pyproject_");
53-
Path tmpFile = Files.createFile(tmpDir.resolve("requirements.txt"));
54-
Files.write(tmpFile, depStrings);
55-
return tmpFile;
68+
protected Path getRequirementsPath() {
69+
throw new UnsupportedOperationException("pip report approach does not use requirements.txt");
70+
}
71+
72+
@Override
73+
protected void cleanupRequirementsPath(Path requirementsPath) {
74+
// no-op: pip report approach does not create temporary files
75+
}
76+
77+
@Override
78+
public Content provideStack() throws IOException {
79+
parseDependencyStrings();
80+
String reportJson = getPipReportOutput(manifest.getParent());
81+
PipReportData data = parsePipReport(reportJson);
82+
83+
Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive");
84+
sbom.addRoot(
85+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
86+
87+
for (String directKey : data.directDeps) {
88+
PipPackage pkg = data.graph.get(directKey);
89+
if (pkg != null) {
90+
addDependencyTree(sbom.getRoot(), pkg, data.graph, sbom, new HashSet<>());
91+
}
92+
}
93+
94+
String manifestContent = Files.readString(manifest);
95+
handleIgnoredDependencies(manifestContent, sbom);
96+
return new Content(
97+
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
5698
}
5799

58100
@Override
59-
protected void cleanupRequirementsPath(Path requirementsPath) throws IOException {
60-
Files.deleteIfExists(requirementsPath);
61-
Files.deleteIfExists(requirementsPath.getParent());
101+
public Content provideComponent() throws IOException {
102+
parseDependencyStrings();
103+
String reportJson = getPipReportOutput(manifest.getParent());
104+
PipReportData data = parsePipReport(reportJson);
105+
106+
Sbom sbom = SbomFactory.newInstance();
107+
sbom.addRoot(
108+
toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest());
109+
110+
for (String directKey : data.directDeps) {
111+
PipPackage pkg = data.graph.get(directKey);
112+
if (pkg != null) {
113+
sbom.addDependency(sbom.getRoot(), toPurl(pkg.name, pkg.version), null);
114+
}
115+
}
116+
117+
String manifestContent = Files.readString(manifest);
118+
handleIgnoredDependencies(manifestContent, sbom);
119+
return new Content(
120+
sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE);
121+
}
122+
123+
private void addDependencyTree(
124+
PackageURL source,
125+
PipPackage pkg,
126+
Map<String, PipPackage> graph,
127+
Sbom sbom,
128+
Set<String> visited) {
129+
PackageURL packageURL = toPurl(pkg.name, pkg.version);
130+
sbom.addDependency(source, packageURL, null);
131+
132+
String key = canonicalize(pkg.name);
133+
if (!visited.add(key)) {
134+
return;
135+
}
136+
137+
for (String childKey : pkg.children) {
138+
PipPackage child = graph.get(childKey);
139+
if (child != null) {
140+
addDependencyTree(packageURL, child, graph, sbom, visited);
141+
}
142+
}
143+
}
144+
145+
String getPipReportOutput(Path manifestDir) {
146+
String envValue = Environment.get(PROP_TRUSTIFY_DA_PIP_REPORT);
147+
if (envValue != null && !envValue.isBlank()) {
148+
return new String(Base64.getDecoder().decode(envValue), StandardCharsets.UTF_8);
149+
}
150+
151+
String pip = findPipBinary();
152+
return Operations.runProcessGetOutput(
153+
manifestDir,
154+
new String[] {pip, "install", "--dry-run", "--ignore-installed", "--report", "-", "."});
155+
}
156+
157+
private String findPipBinary() {
158+
String pip = Operations.getCustomPathOrElse("pip3");
159+
try {
160+
Operations.runProcess(pip, "--version");
161+
return pip;
162+
} catch (Exception e) {
163+
pip = Operations.getCustomPathOrElse("pip");
164+
Operations.runProcess(pip, "--version");
165+
return pip;
166+
}
167+
}
168+
169+
PipReportData parsePipReport(String reportJson) throws IOException {
170+
JsonNode report = objectMapper.readTree(reportJson);
171+
JsonNode installArray = report.get("install");
172+
if (installArray == null || !installArray.isArray()) {
173+
return new PipReportData(List.of(), Map.of());
174+
}
175+
176+
// Find root entry (has dir_info in download_info) and collect non-root packages
177+
JsonNode rootEntry = null;
178+
List<JsonNode> nonRootPackages = new ArrayList<>();
179+
for (JsonNode entry : installArray) {
180+
JsonNode downloadInfo = entry.get("download_info");
181+
if (downloadInfo != null && downloadInfo.has("dir_info")) {
182+
rootEntry = entry;
183+
} else {
184+
nonRootPackages.add(entry);
185+
}
186+
}
187+
188+
// Extract direct dependency names from root's requires_dist
189+
Set<String> directDepNames = new HashSet<>();
190+
if (rootEntry != null) {
191+
JsonNode metadata = rootEntry.get("metadata");
192+
if (metadata != null) {
193+
JsonNode requiresDist = metadata.get("requires_dist");
194+
if (requiresDist != null && requiresDist.isArray()) {
195+
for (JsonNode req : requiresDist) {
196+
String reqStr = req.asText();
197+
if (hasExtraMarker(reqStr)) {
198+
continue;
199+
}
200+
String name = extractDepName(reqStr);
201+
if (name != null) {
202+
directDepNames.add(canonicalize(name));
203+
}
204+
}
205+
}
206+
}
207+
}
208+
209+
// Build graph from non-root packages
210+
Map<String, PipPackage> graph = new HashMap<>();
211+
for (JsonNode pkg : nonRootPackages) {
212+
JsonNode metadata = pkg.get("metadata");
213+
if (metadata == null) {
214+
continue;
215+
}
216+
String name = metadata.has("name") ? metadata.get("name").asText() : null;
217+
String version = metadata.has("version") ? metadata.get("version").asText() : null;
218+
if (name == null) {
219+
continue;
220+
}
221+
String key = canonicalize(name);
222+
graph.put(key, new PipPackage(name, version, new ArrayList<>()));
223+
}
224+
225+
// Build children from each package's requires_dist
226+
for (JsonNode pkg : nonRootPackages) {
227+
JsonNode metadata = pkg.get("metadata");
228+
if (metadata == null) {
229+
continue;
230+
}
231+
String name = metadata.has("name") ? metadata.get("name").asText() : null;
232+
if (name == null) {
233+
continue;
234+
}
235+
String key = canonicalize(name);
236+
PipPackage pipPkg = graph.get(key);
237+
if (pipPkg == null) {
238+
continue;
239+
}
240+
JsonNode requiresDist = metadata.get("requires_dist");
241+
if (requiresDist == null || !requiresDist.isArray()) {
242+
continue;
243+
}
244+
for (JsonNode req : requiresDist) {
245+
String reqStr = req.asText();
246+
if (hasExtraMarker(reqStr)) {
247+
continue;
248+
}
249+
String depName = extractDepName(reqStr);
250+
if (depName == null) {
251+
continue;
252+
}
253+
String depKey = canonicalize(depName);
254+
if (graph.containsKey(depKey)) {
255+
pipPkg.children.add(depKey);
256+
}
257+
}
258+
}
259+
260+
List<String> directDeps =
261+
directDepNames.stream().filter(graph::containsKey).collect(Collectors.toList());
262+
return new PipReportData(directDeps, graph);
263+
}
264+
265+
static boolean hasExtraMarker(String req) {
266+
return EXTRA_MARKER_PATTERN.matcher(req).find();
267+
}
268+
269+
static String extractDepName(String req) {
270+
Matcher m = DEP_NAME_PATTERN.matcher(req);
271+
return m.find() ? m.group(1) : null;
272+
}
273+
274+
static String canonicalize(String name) {
275+
return name.toLowerCase().replaceAll("[-_.]+", "-");
62276
}
63277

64278
private TomlParseResult getToml() throws IOException {
@@ -259,4 +473,26 @@ private static String convertTilde(String ver) {
259473
String fullVer = major + "." + minor + "." + patch;
260474
return ">=" + fullVer + ",<" + major + "." + (minor + 1) + ".0";
261475
}
476+
477+
static final class PipPackage {
478+
final String name;
479+
final String version;
480+
final List<String> children;
481+
482+
PipPackage(String name, String version, List<String> children) {
483+
this.name = name;
484+
this.version = version;
485+
this.children = children;
486+
}
487+
}
488+
489+
static final class PipReportData {
490+
final List<String> directDeps;
491+
final Map<String, PipPackage> graph;
492+
493+
PipReportData(List<String> directDeps, Map<String, PipPackage> graph) {
494+
this.directDeps = directDeps;
495+
this.graph = graph;
496+
}
497+
}
262498
}

0 commit comments

Comments
 (0)