Skip to content

Commit 159765c

Browse files
authored
fix(python): handle PEP 440 direct references and path dependencies in uv export (guacsec#490)
## Summary - Skip PEP 440 direct references (`name @ url`) and path dependencies (`./`, `../`, `/`) in `parseUvExport()` with `log.fine()` warning instead of throwing `IOException` - Set `currentKey = null` after skipping to prevent `# via` comments from corrupting the dependency graph of other packages - Add test fixture with mixed direct refs and normal packages, plus 7 new unit tests covering skip behavior, graph integrity, and integration with `provideStack()`/`provideComponent()` Implements [TC-4538](https://redhat.atlassian.net/browse/TC-4538) ## Test plan - [x] `parseUvExport()` skips PEP 440 direct references without throwing - [x] `parseUvExport()` skips path dependencies (`./`, `../`, `/`) without throwing - [x] Packages following skipped lines are parsed correctly - [x] `# via` comments after skipped packages do not corrupt the graph - [x] `provideStack()` and `provideComponent()` succeed with direct refs in export output - [x] Existing tests pass (27/27) - [x] `mvn spotless:check` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) [TC-4538]: https://redhat.atlassian.net/browse/TC-4538?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ ## Summary by Sourcery Handle uv export entries that lack pinned versions by skipping specific dependency types and expand test coverage to ensure dependency graphs and SBOM generation remain correct. Bug Fixes: - Skip PEP 440 direct reference entries in uv export parsing to avoid failures on unpinned dependencies. - Skip path-based dependency entries in uv export parsing to prevent IO exceptions on non-versioned packages. - Reset dependency tracking state after skipped entries so subsequent '# via' comments do not corrupt the dependency graph. Tests: - Add unit tests verifying skipping of direct references and path dependencies in uv export output. - Add tests ensuring '# via' comments following skipped entries do not create incorrect parent-child relationships. - Add fixture-based tests validating correct parsing of mixed direct references, path dependencies, and normal packages. - Add integration-style tests confirming provideStack and provideComponent work when uv export contains direct references and path dependencies. --------- Signed-off-by: Adva Oren <aoren@redhat.com>
1 parent a6b1326 commit 159765c

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,21 @@ UvDependencyData parseUvExport(String exportOutput) throws IOException {
193193
// Package line: name==version [; env-marker]
194194
if (!line.startsWith(" ") && !trimmed.startsWith("#")) {
195195
inViaBlock = false;
196+
197+
// PEP 440 direct references (name @ url) — skip, no pinned version available
198+
if (isDirectReference(trimmed)) {
199+
log.fine("Skipping PEP 440 direct reference: " + trimmed);
200+
currentKey = null;
201+
continue;
202+
}
203+
204+
// Path dependencies (./local, ../local, /absolute, ~/home, C:\win) — skip
205+
if (isPathDependency(trimmed)) {
206+
log.fine("Skipping path dependency: " + trimmed);
207+
currentKey = null;
208+
continue;
209+
}
210+
196211
if (!trimmed.contains("==")) {
197212
throw new IOException("uv export: package '" + trimmed + "' has no pinned version");
198213
}
@@ -284,6 +299,22 @@ private static String parseEditableInstall(
284299
}
285300
}
286301

302+
private static final Pattern WINDOWS_DRIVE_PATH = Pattern.compile("^[a-zA-Z]:[/\\\\]");
303+
304+
/** Returns {@code true} if the line is a PEP 440 direct reference ({@code name @ url}). */
305+
static boolean isDirectReference(String trimmedLine) {
306+
return trimmedLine.contains(" @ ");
307+
}
308+
309+
/** Returns {@code true} if the line is a local or absolute path dependency. */
310+
static boolean isPathDependency(String trimmedLine) {
311+
return trimmedLine.startsWith("./")
312+
|| trimmedLine.startsWith("../")
313+
|| trimmedLine.startsWith("/")
314+
|| trimmedLine.startsWith("~/")
315+
|| WINDOWS_DRIVE_PATH.matcher(trimmedLine).find();
316+
}
317+
287318
private static final Pattern BARE_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");
288319

289320
private static void recordViaParent(

src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,212 @@ void test_parseUvExport_via_skips_non_bare_package_names() throws IOException {
392392
assertThat(data.graph().values().stream().allMatch(p -> p.children().isEmpty())).isTrue();
393393
}
394394

395+
/** Verifies that PEP 440 direct references (name @ url) are skipped without throwing. */
396+
@Test
397+
void test_parseUvExport_skips_direct_references() throws IOException {
398+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
399+
var provider = new PythonUvProvider(pyprojectPath);
400+
401+
String exportOutput =
402+
"# This file was autogenerated by uv\n"
403+
+ "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef1234567890\n"
404+
+ " # via requests\n"
405+
+ "anyio==3.6.2\n"
406+
+ " # via test-project\n";
407+
408+
// Given/When
409+
var data = provider.parseUvExport(exportOutput);
410+
411+
// Then — direct reference is skipped, not in graph
412+
assertThat(data.graph()).doesNotContainKey("certifi");
413+
// anyio after the skipped line is still parsed correctly
414+
assertThat(data.graph()).containsKey("anyio");
415+
assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2");
416+
assertThat(data.directDeps()).contains("anyio");
417+
}
418+
419+
/** Verifies that path dependencies (./local-package) are skipped without throwing. */
420+
@Test
421+
void test_parseUvExport_skips_path_dependencies() throws IOException {
422+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
423+
var provider = new PythonUvProvider(pyprojectPath);
424+
425+
String exportOutput =
426+
"# This file was autogenerated by uv\n"
427+
+ "./local-package\n"
428+
+ " # via test-project\n"
429+
+ "../sibling-package\n"
430+
+ " # via test-project\n"
431+
+ "/absolute/path/package\n"
432+
+ " # via test-project\n"
433+
+ "~/home-relative/package\n"
434+
+ " # via test-project\n"
435+
+ "C:\\Users\\dev\\my-package\n"
436+
+ " # via test-project\n"
437+
+ "anyio==3.6.2\n"
438+
+ " # via test-project\n";
439+
440+
// Given/When
441+
var data = provider.parseUvExport(exportOutput);
442+
443+
// Then — all path dependency forms are skipped
444+
assertThat(data.graph()).doesNotContainKey("./local-package");
445+
assertThat(data.graph()).doesNotContainKey("../sibling-package");
446+
assertThat(data.graph()).doesNotContainKey("/absolute/path/package");
447+
assertThat(data.graph()).doesNotContainKey("~/home-relative/package");
448+
assertThat(data.graph()).doesNotContainKey("c:\\users\\dev\\my-package");
449+
// anyio is still parsed correctly
450+
assertThat(data.graph()).containsKey("anyio");
451+
assertThat(data.directDeps()).contains("anyio");
452+
}
453+
454+
/**
455+
* Verifies that # via comments after a skipped path dependency do not corrupt the graph by
456+
* attaching to the previous package.
457+
*/
458+
@Test
459+
void test_parseUvExport_via_after_skipped_path_dep_does_not_corrupt_graph() throws IOException {
460+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
461+
var provider = new PythonUvProvider(pyprojectPath);
462+
463+
// Given — anyio is parsed first, then a path dependency is skipped. The "# via requests"
464+
// after the skipped path dep should NOT make anyio a child of requests.
465+
String exportOutput =
466+
"# This file was autogenerated by uv\n"
467+
+ "anyio==3.6.2\n"
468+
+ " # via test-project\n"
469+
+ "./local-package\n"
470+
+ " # via requests\n"
471+
+ "requests==2.25.1\n"
472+
+ " # via test-project\n";
473+
474+
// When
475+
var data = provider.parseUvExport(exportOutput);
476+
477+
// Then — requests should NOT have anyio as a child (that would be a corruption)
478+
assertThat(data.graph().get("requests").children()).doesNotContain("anyio");
479+
// Both anyio and requests are direct deps
480+
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "requests");
481+
}
482+
483+
/**
484+
* Verifies that # via comments after skipped direct references do not create incorrect
485+
* parent-child relationships with the previous package.
486+
*/
487+
@Test
488+
void test_parseUvExport_via_after_skipped_does_not_corrupt_graph() throws IOException {
489+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
490+
var provider = new PythonUvProvider(pyprojectPath);
491+
492+
// anyio is parsed first, then a direct reference is skipped. The "# via requests" after
493+
// the skipped package should NOT make anyio a child of requests.
494+
String exportOutput =
495+
"# This file was autogenerated by uv\n"
496+
+ "anyio==3.6.2\n"
497+
+ " # via test-project\n"
498+
+ "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef\n"
499+
+ " # via requests\n"
500+
+ "requests==2.25.1\n"
501+
+ " # via test-project\n";
502+
503+
// Given/When
504+
var data = provider.parseUvExport(exportOutput);
505+
506+
// Then — requests should NOT have anyio as a child (that would be a corruption)
507+
assertThat(data.graph().get("requests").children()).doesNotContain("anyio");
508+
// certifi is skipped
509+
assertThat(data.graph()).doesNotContainKey("certifi");
510+
// Both anyio and requests are direct deps
511+
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "requests");
512+
}
513+
514+
/**
515+
* Verifies that parseUvExport correctly handles a fixture file containing both direct references
516+
* and path dependencies mixed with normal packages.
517+
*/
518+
@Test
519+
void test_parseUvExport_with_direct_refs_fixture() throws IOException {
520+
Path exportPath = Path.of(UV_FIXTURE, "uv_export_direct_refs.txt");
521+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
522+
var provider = new PythonUvProvider(pyprojectPath);
523+
String exportOutput = Files.readString(exportPath);
524+
525+
// Given/When
526+
var data = provider.parseUvExport(exportOutput);
527+
528+
// Then — direct reference and path dependency are skipped
529+
assertThat(data.graph()).doesNotContainKey("certifi");
530+
assertThat(data.graph()).doesNotContainKey("./local-package");
531+
532+
// Normal packages are parsed correctly
533+
assertThat(data.graph()).containsKeys("anyio", "flask", "requests", "idna", "sniffio");
534+
assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2");
535+
assertThat(data.graph().get("flask").version()).isEqualTo("2.0.3");
536+
assertThat(data.graph().get("requests").version()).isEqualTo("2.25.1");
537+
538+
// Direct deps are correctly identified
539+
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests");
540+
541+
// charset-normalizer is a child of requests (not corrupted by the skipped certifi)
542+
assertThat(data.graph().get("requests").children()).contains("charset-normalizer");
543+
}
544+
545+
/**
546+
* Verifies that provideStack succeeds with export output containing direct references and path
547+
* dependencies.
548+
*/
549+
@Test
550+
void test_provideStack_with_direct_refs() throws IOException {
551+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
552+
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));
553+
554+
System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
555+
try {
556+
var provider = new PythonUvProvider(pyprojectPath);
557+
var content = provider.provideStack();
558+
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
559+
String sbomJson = new String(content.buffer);
560+
assertThat(sbomJson).contains("CycloneDX");
561+
// Skipped packages should not appear
562+
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
563+
// Normal packages should appear
564+
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
565+
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
566+
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
567+
} catch (RuntimeException | NoClassDefFoundError e) {
568+
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
569+
} finally {
570+
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
571+
}
572+
}
573+
574+
/**
575+
* Verifies that provideComponent succeeds with export output containing direct references and
576+
* path dependencies.
577+
*/
578+
@Test
579+
void test_provideComponent_with_direct_refs() throws IOException {
580+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
581+
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));
582+
583+
System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
584+
try {
585+
var provider = new PythonUvProvider(pyprojectPath);
586+
var content = provider.provideComponent();
587+
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
588+
String sbomJson = new String(content.buffer);
589+
assertThat(sbomJson).contains("CycloneDX");
590+
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
591+
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
592+
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
593+
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
594+
} catch (RuntimeException | NoClassDefFoundError e) {
595+
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
596+
} finally {
597+
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
598+
}
599+
}
600+
395601
@Test
396602
void test_parseUvExport_throws_on_unpinned_version() {
397603
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# This file was autogenerated by uv via the following command:
2+
# uv export --format requirements.txt --frozen --no-hashes --no-dev
3+
anyio==3.6.2
4+
# via test-project
5+
certifi @ git+https://github.com/certifi/python-certifi.git@abcdef1234567890
6+
# via requests
7+
./local-package
8+
# via test-project
9+
charset-normalizer==3.1.0
10+
# via requests
11+
flask==2.0.3
12+
# via test-project
13+
idna==3.4
14+
# via
15+
# anyio
16+
# requests
17+
requests==2.25.1
18+
# via test-project
19+
sniffio==1.3.0
20+
# via anyio

0 commit comments

Comments
 (0)