diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 3a7d6c60..64c8452b 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -35,6 +35,7 @@ import io.github.guacsec.trustifyda.tools.Ecosystem; import io.github.guacsec.trustifyda.tools.Operations; import io.github.guacsec.trustifyda.utils.Environment; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; import io.github.guacsec.trustifyda.utils.WorkspaceUtils; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMultipart; @@ -49,8 +50,10 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.AbstractMap; @@ -66,8 +69,12 @@ import java.util.concurrent.CompletionException; import java.util.function.Function; import java.util.function.Supplier; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; +import org.tomlj.TomlArray; +import org.tomlj.TomlParseResult; +import org.tomlj.TomlTable; /** Concrete implementation of the Exhort {@link Api} Service. */ public final class ExhortApi implements Api { @@ -842,7 +849,7 @@ int resolveBatchConcurrency() { } private static final Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE = - Set.of("**/node_modules/**", "**/.git/**"); + Set.of("**/node_modules/**", "**/.git/**", "**/__pycache__/**", "**/.venv/**"); /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */ Set resolveIgnorePatterns(Set callerPatterns) { @@ -875,6 +882,15 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte return discoverCargoManifests(workspaceDir, ignorePatterns); } + // uv workspace: pyproject.toml with [tool.uv.workspace] + uv.lock + if (Files.isRegularFile(workspaceDir.resolve("pyproject.toml")) + && Files.isRegularFile(workspaceDir.resolve("uv.lock"))) { + List uvManifests = discoverUvWorkspaceMembers(workspaceDir, ignorePatterns); + if (!uvManifests.isEmpty()) { + return uvManifests; + } + } + // JS workspace: require package.json + a lock file Path packageJson = workspaceDir.resolve("package.json"); boolean hasJsLock = @@ -930,6 +946,86 @@ private List discoverCargoManifests(Path workspaceDir, Set ignoreP } } + /** + * Discover all pyproject.toml manifest paths in a uv workspace. Parses the root pyproject.toml + * for {@code [tool.uv.workspace]} member and exclude globs, then walks the filesystem to find + * matching member directories. + */ + private List discoverUvWorkspaceMembers(Path workspaceDir, Set ignorePatterns) { + try { + Path rootPyproject = workspaceDir.resolve("pyproject.toml"); + TomlParseResult toml = PyprojectTomlUtils.parseToml(rootPyproject); + + TomlTable workspaceConfig = toml.getTable("tool.uv.workspace"); + if (workspaceConfig == null) { + return Collections.emptyList(); + } + + TomlArray membersArray = workspaceConfig.getArray("members"); + if (membersArray == null || membersArray.isEmpty()) { + return Collections.emptyList(); + } + + List memberPatterns = toStringList(membersArray); + if (memberPatterns.isEmpty()) { + return Collections.emptyList(); + } + + List excludePatterns = toStringList(workspaceConfig.getArray("exclude")); + + List memberMatchers = + memberPatterns.stream() + .map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p)) + .toList(); + + List excludeMatchers = + excludePatterns.stream() + .map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p)) + .toList(); + + List manifests = new ArrayList<>(); + try (var stream = Files.walk(workspaceDir)) { + stream + .filter(p -> p.getFileName().toString().equals("pyproject.toml")) + .filter(p -> !p.equals(rootPyproject)) + .forEach( + p -> { + Path relative = workspaceDir.relativize(p.getParent()); + boolean matchesMember = + memberMatchers.stream().anyMatch(m -> m.matches(relative)); + boolean matchesExclude = + excludeMatchers.stream().anyMatch(m -> m.matches(relative)); + if (matchesMember && !matchesExclude) { + manifests.add(p); + } + }); + } + + if (PyprojectTomlUtils.getProjectName(toml) != null) { + manifests.addFirst(rootPyproject); + } + + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to discover uv workspace members", e); + return Collections.emptyList(); + } + } + + static List toStringList(TomlArray array) { + if (array == null) { + return List.of(); + } + List result = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + String s = array.getString(i); + if (s != null && !s.isBlank()) { + result.add(s.trim()); + } + } + return result; + } + /** * Checks whether a package.json has "private": true, meaning it should not be analyzed as a * publishable package. diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/WorkspaceUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/WorkspaceUtils.java index 7e56588f..b9df555e 100644 --- a/src/main/java/io/github/guacsec/trustifyda/utils/WorkspaceUtils.java +++ b/src/main/java/io/github/guacsec/trustifyda/utils/WorkspaceUtils.java @@ -43,7 +43,17 @@ public static List filterByIgnorePatterns( List matchers = ignorePatterns.stream() - .map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p)) + .flatMap( + p -> { + var fs = FileSystems.getDefault(); + java.util.stream.Stream.Builder b = + java.util.stream.Stream.builder(); + b.add(fs.getPathMatcher("glob:" + p)); + if (p.startsWith("**/")) { + b.add(fs.getPathMatcher("glob:" + p.substring(3))); + } + return b.build(); + }) .toList(); return manifests.stream() diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/UvWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/UvWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..936a51c3 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/UvWorkspaceDiscoveryTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class UvWorkspaceDiscoveryTest { + + private static final Path UV_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/uv"); + + @Test + void discoverWorkspaceManifests_uvRootPackageWorkspace() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml")); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("pyproject.toml")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "packages" + + File.separator + + "mid-pkg" + + File.separator + + "pyproject.toml")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "packages" + + File.separator + + "sub-pkg" + + File.separator + + "pyproject.toml")); + } + + @Test + void discoverWorkspaceManifests_uvVirtualWorkspace() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_virtual").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests).allMatch(p -> p.toString().endsWith("pyproject.toml")); + assertThat(manifests).noneMatch(p -> p.equals(workspaceDir.resolve("pyproject.toml"))); + assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-a")); + assertThat(manifests).anyMatch(p -> p.toString().contains("pkg-b")); + } + + @Test + void discoverWorkspaceManifests_uvExcludePatterns() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_exclude").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).anyMatch(p -> p.toString().contains("core")); + assertThat(manifests).noneMatch(p -> p.toString().contains("internal")); + } + + @Test + void discoverWorkspaceManifests_uvNestedMultiplePatterns() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "apps" + File.separator + "backend" + File.separator + "pyproject.toml")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "libs" + File.separator + "core" + File.separator + "pyproject.toml")); + } + + @Test + void discoverWorkspaceManifests_uvNoLockFile() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_lock").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + + @Test + void discoverWorkspaceManifests_uvNoWorkspaceConfig() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_no_config").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + + @Test + void discoverWorkspaceManifests_uvIgnorePatternFiltering() throws IOException { + Path workspaceDir = UV_FIXTURES.resolve("uv_workspace_nested").toAbsolutePath().normalize(); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/libs/**")); + + assertThat(manifests).anyMatch(p -> p.toString().contains("backend")); + assertThat(manifests) + .noneMatch(p -> p.toString().contains(File.separator + "libs" + File.separator)); + } +} diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/mid-pkg/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/mid-pkg/pyproject.toml new file mode 100644 index 00000000..e66920d8 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/mid-pkg/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "mid-pkg" +version = "0.1.0" +dependencies = [ + "sub-pkg", +] + +[tool.uv.sources] +sub-pkg = { workspace = true } diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/sub-pkg/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/sub-pkg/pyproject.toml new file mode 100644 index 00000000..39aac1dd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/packages/sub-pkg/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "sub-pkg" +version = "0.1.0" +dependencies = [ + "requests>=2.0", +] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/pyproject.toml new file mode 100644 index 00000000..2d6cf260 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "uv-mono" +version = "0.1.0" +dependencies = [ + "mid-pkg", + "flask==2.0.3" +] + +[tool.uv.sources] +mid-pkg = { workspace = true } + +[tool.uv.workspace] +members = ["packages/*"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace/uv.lock b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/core/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/core/pyproject.toml new file mode 100644 index 00000000..f6838f3e --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/core/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "core" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/internal/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/internal/pyproject.toml new file mode 100644 index 00000000..97bbdd44 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/packages/internal/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "internal" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/pyproject.toml new file mode 100644 index 00000000..f7ae8e5a --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "exclude-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] +exclude = ["packages/internal"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/uv.lock b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_exclude/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/apps/backend/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/apps/backend/pyproject.toml new file mode 100644 index 00000000..f11cb651 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/apps/backend/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "backend" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/libs/core/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/libs/core/pyproject.toml new file mode 100644 index 00000000..f6838f3e --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/libs/core/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "core" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/pyproject.toml new file mode 100644 index 00000000..e3dce5c4 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "nested-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["apps/*", "libs/*"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/uv.lock b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_nested/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/pyproject.toml new file mode 100644 index 00000000..8e7fb78a --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "no-config" +version = "0.1.0" +dependencies = ["requests>=2.0"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/uv.lock b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_config/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_lock/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_lock/pyproject.toml new file mode 100644 index 00000000..a44ce805 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_no_lock/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "no-lock-workspace" +version = "0.1.0" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-a/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-a/pyproject.toml new file mode 100644 index 00000000..840b5efd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-a/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "pkg-a" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-b/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-b/pyproject.toml new file mode 100644 index 00000000..f1e7ed78 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/packages/pkg-b/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "pkg-b" +version = "0.1.0" +dependencies = [] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/pyproject.toml b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/pyproject.toml new file mode 100644 index 00000000..2b03125d --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/pyproject.toml @@ -0,0 +1,2 @@ +[tool.uv.workspace] +members = ["packages/*"] diff --git a/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/uv.lock b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/uv.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/uv/uv_workspace_virtual/uv.lock @@ -0,0 +1 @@ +version = 1