Skip to content

Commit 6549a64

Browse files
committed
fix(python): handle PEP 440 direct references and path dependencies in uv export
Skip PEP 440 direct references (name @ url) and path dependencies (./, ../, /) in parseUvExport() with a log.fine() warning instead of throwing an IOException. These lines lack pinned versions needed for the dependency graph. Set currentKey to null after skipping to prevent # via comments from corrupting the graph of other packages. Implements TC-4538 Signed-off-by: Adva Oren <aoren@redhat.com> Assisted-by: Claude Code
1 parent 95e2da5 commit 6549a64

3 files changed

Lines changed: 206 additions & 0 deletions

File tree

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

Lines changed: 15 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 (trimmed.contains(" @ ")) {
199+
log.fine("Skipping PEP 440 direct reference: " + trimmed);
200+
currentKey = null;
201+
continue;
202+
}
203+
204+
// Path dependencies (./local, ../local, /absolute) — skip, no pinned version available
205+
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("/")) {
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
}

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

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,177 @@ 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+
+ "anyio==3.6.2\n"
434+
+ " # via test-project\n";
435+
436+
// Given/When
437+
var data = provider.parseUvExport(exportOutput);
438+
439+
// Then — path dependencies are skipped
440+
assertThat(data.graph()).doesNotContainKey("./local-package");
441+
assertThat(data.graph()).doesNotContainKey("../sibling-package");
442+
assertThat(data.graph()).doesNotContainKey("/absolute/path/package");
443+
// anyio is still parsed correctly
444+
assertThat(data.graph()).containsKey("anyio");
445+
assertThat(data.directDeps()).contains("anyio");
446+
}
447+
448+
/**
449+
* Verifies that # via comments after skipped packages do not create incorrect parent-child
450+
* relationships with the previous package.
451+
*/
452+
@Test
453+
void test_parseUvExport_via_after_skipped_does_not_corrupt_graph() throws IOException {
454+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
455+
var provider = new PythonUvProvider(pyprojectPath);
456+
457+
// anyio is parsed first, then a direct reference is skipped. The "# via requests" after
458+
// the skipped package should NOT make anyio a child of requests.
459+
String exportOutput =
460+
"# This file was autogenerated by uv\n"
461+
+ "anyio==3.6.2\n"
462+
+ " # via test-project\n"
463+
+ "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef\n"
464+
+ " # via requests\n"
465+
+ "requests==2.25.1\n"
466+
+ " # via test-project\n";
467+
468+
// Given/When
469+
var data = provider.parseUvExport(exportOutput);
470+
471+
// Then — requests should NOT have anyio as a child (that would be a corruption)
472+
assertThat(data.graph().get("requests").children()).doesNotContain("anyio");
473+
// certifi is skipped
474+
assertThat(data.graph()).doesNotContainKey("certifi");
475+
// Both anyio and requests are direct deps
476+
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "requests");
477+
}
478+
479+
/**
480+
* Verifies that parseUvExport correctly handles a fixture file containing both direct references
481+
* and path dependencies mixed with normal packages.
482+
*/
483+
@Test
484+
void test_parseUvExport_with_direct_refs_fixture() throws IOException {
485+
Path exportPath = Path.of(UV_FIXTURE, "uv_export_direct_refs.txt");
486+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
487+
var provider = new PythonUvProvider(pyprojectPath);
488+
String exportOutput = Files.readString(exportPath);
489+
490+
// Given/When
491+
var data = provider.parseUvExport(exportOutput);
492+
493+
// Then — direct reference and path dependency are skipped
494+
assertThat(data.graph()).doesNotContainKey("certifi");
495+
assertThat(data.graph()).doesNotContainKey("./local-package");
496+
497+
// Normal packages are parsed correctly
498+
assertThat(data.graph()).containsKeys("anyio", "flask", "requests", "idna", "sniffio");
499+
assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2");
500+
assertThat(data.graph().get("flask").version()).isEqualTo("2.0.3");
501+
assertThat(data.graph().get("requests").version()).isEqualTo("2.25.1");
502+
503+
// Direct deps are correctly identified
504+
assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests");
505+
506+
// charset-normalizer is a child of requests (not corrupted by the skipped certifi)
507+
assertThat(data.graph().get("requests").children()).contains("charset-normalizer");
508+
}
509+
510+
/**
511+
* Verifies that provideStack succeeds with export output containing direct references and path
512+
* dependencies.
513+
*/
514+
@Test
515+
void test_provideStack_with_direct_refs() throws IOException {
516+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
517+
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));
518+
519+
System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
520+
try {
521+
var provider = new PythonUvProvider(pyprojectPath);
522+
var content = provider.provideStack();
523+
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
524+
String sbomJson = new String(content.buffer);
525+
assertThat(sbomJson).contains("CycloneDX");
526+
// Skipped packages should not appear
527+
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
528+
// Normal packages should appear
529+
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
530+
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
531+
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
532+
} catch (RuntimeException | NoClassDefFoundError e) {
533+
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
534+
} finally {
535+
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
536+
}
537+
}
538+
539+
/**
540+
* Verifies that provideComponent succeeds with export output containing direct references and
541+
* path dependencies.
542+
*/
543+
@Test
544+
void test_provideComponent_with_direct_refs() throws IOException {
545+
Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml");
546+
String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export_direct_refs.txt"));
547+
548+
System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput);
549+
try {
550+
var provider = new PythonUvProvider(pyprojectPath);
551+
var content = provider.provideComponent();
552+
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
553+
String sbomJson = new String(content.buffer);
554+
assertThat(sbomJson).contains("CycloneDX");
555+
assertThat(sbomJson).doesNotContain("pkg:pypi/certifi@");
556+
assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2");
557+
assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3");
558+
assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1");
559+
} catch (RuntimeException | NoClassDefFoundError e) {
560+
Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage());
561+
} finally {
562+
System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT);
563+
}
564+
}
565+
395566
@Test
396567
void test_parseUvExport_throws_on_unpinned_version() {
397568
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)