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..eed0e0ee 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -30,6 +30,7 @@ import io.github.guacsec.trustifyda.image.ImageUtils; import io.github.guacsec.trustifyda.license.LicenseCheck; import io.github.guacsec.trustifyda.logging.LoggersFactory; +import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata; import io.github.guacsec.trustifyda.tools.Ecosystem; @@ -875,6 +876,14 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte return discoverCargoManifests(workspaceDir, ignorePatterns); } + // Go workspace: go.work + if (Files.isRegularFile(workspaceDir.resolve("go.work"))) { + List goManifests = discoverGoWorkspaceModules(workspaceDir, ignorePatterns); + if (!goManifests.isEmpty()) { + return goManifests; + } + } + // JS workspace: require package.json + a lock file Path packageJson = workspaceDir.resolve("package.json"); boolean hasJsLock = @@ -930,6 +939,43 @@ private List discoverCargoManifests(Path workspaceDir, Set ignoreP } } + /** + * Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get + * workspace members. + */ + private List discoverGoWorkspaceModules(Path workspaceDir, Set ignorePatterns) { + try { + String goBin = Operations.getCustomPathOrElse("go"); + Path goWork = workspaceDir.resolve("go.work"); + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + workspaceDir, new String[] {goBin, "work", "edit", "-json", goWork.toString()}, null); + if (output.getExitCode() != 0) { + LOG.warning("go work edit -json failed with exit code " + output.getExitCode()); + return Collections.emptyList(); + } + GoWorkspace workspace = mapper.readValue(output.getOutput(), GoWorkspace.class); + if (workspace.use() == null || workspace.use().isEmpty()) { + return Collections.emptyList(); + } + List manifests = new ArrayList<>(); + for (var entry : workspace.use()) { + if (entry.diskPath() == null || entry.diskPath().isBlank()) { + continue; + } + Path moduleDir = workspaceDir.resolve(entry.diskPath()).normalize(); + Path goMod = moduleDir.resolve("go.mod"); + if (Files.isRegularFile(goMod)) { + manifests.add(goMod); + } + } + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns); + } catch (Exception e) { + LOG.warning("Failed to discover Go workspace modules: " + e.getMessage()); + return Collections.emptyList(); + } + } + /** * 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/providers/golang/model/GoWorkspace.java b/src/main/java/io/github/guacsec/trustifyda/providers/golang/model/GoWorkspace.java new file mode 100644 index 00000000..20c70102 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/golang/model/GoWorkspace.java @@ -0,0 +1,29 @@ +/* + * 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.providers.golang.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** JSON model for {@code go work edit -json} output. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoWorkspace(@JsonProperty("Use") List use) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record UseEntry(@JsonProperty("DiskPath") String diskPath) {} +} 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/main/java/module-info.java b/src/main/java/module-info.java index c04187c3..a4d61227 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -18,6 +18,8 @@ com.fasterxml.jackson.databind; opens io.github.guacsec.trustifyda.providers.rust.model to com.fasterxml.jackson.databind; + opens io.github.guacsec.trustifyda.providers.golang.model to + com.fasterxml.jackson.databind; exports io.github.guacsec.trustifyda; exports io.github.guacsec.trustifyda.impl; @@ -35,6 +37,7 @@ exports io.github.guacsec.trustifyda.providers.javascript.model; exports io.github.guacsec.trustifyda.providers.javascript.workspace; exports io.github.guacsec.trustifyda.providers.rust.model; + exports io.github.guacsec.trustifyda.providers.golang.model; exports io.github.guacsec.trustifyda.logging; exports io.github.guacsec.trustifyda.image; exports io.github.guacsec.trustifyda.license; diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..9b298edf --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java @@ -0,0 +1,254 @@ +/* + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; +import io.github.guacsec.trustifyda.tools.Operations; +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.MockedStatic; +import org.mockito.Mockito; + +class GoWorkspaceDiscoveryTest { + + private static final Path GO_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/go"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // --- GoWorkspace deserialization tests --- + + @Test + void goWorkspace_deserializesStandardOutput() throws Exception { + String json = + """ + { + "Go": "1.22", + "Use": [ + {"DiskPath": "./module-a"}, + {"DiskPath": "./module-b"} + ] + } + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + + assertThat(workspace.use()).hasSize(2); + assertThat(workspace.use().getFirst().diskPath()).isEqualTo("./module-a"); + assertThat(workspace.use().get(1).diskPath()).isEqualTo("./module-b"); + } + + @Test + void goWorkspace_handlesNullUse() throws Exception { + String json = + """ + {"Go": "1.22"} + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).isNull(); + } + + @Test + void goWorkspace_handlesEmptyUse() throws Exception { + String json = + """ + {"Go": "1.22", "Use": []} + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).isEmpty(); + } + + @Test + void goWorkspace_ignoresUnknownFields() throws Exception { + String json = + """ + { + "Go": "1.22", + "Use": [{"DiskPath": "./mod"}], + "Replace": null, + "Toolchain": {"Name": "go1.22.0"} + } + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).hasSize(1); + } + + // --- discoverWorkspaceManifests tests (require mocking Operations) --- + + @Test + void discoverWorkspaceManifests_goMultiModule() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./module-a", "./module-b"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + 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("go.mod")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("module-a" + File.separator + "go.mod")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("module-b" + File.separator + "go.mod")); + } + } + + @Test + void discoverWorkspaceManifests_nestedModules() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains("libs" + File.separator + "core" + File.separator + "go.mod")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains("libs" + File.separator + "util" + File.separator + "go.mod")); + } + } + + @Test + void discoverWorkspaceManifests_singleModule() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_single").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./mymod"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst().toString()).contains("mymod" + File.separator + "go.mod"); + } + } + + @Test + void discoverWorkspaceManifests_missingModuleDirectory() throws IOException { + Path workspaceDir = + GO_FIXTURES.resolve("go_workspace_missing_module").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./existing", "./nonexistent"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst().toString()).contains("existing"); + assertThat(manifests).noneMatch(p -> p.toString().contains("nonexistent")); + } + } + + @Test + void discoverWorkspaceManifests_goCommandFails() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go"); + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("", "go: not found", 1)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + } + + @Test + void discoverWorkspaceManifests_emptyUseList() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + String goWorkJson = + """ + {"Go": "1.22", "Use": []} + """; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + } + + @Test + void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/util/**")); + + assertThat(manifests).anyMatch(p -> p.toString().contains("core")); + assertThat(manifests).noneMatch(p -> p.toString().contains("util")); + } + } + + // --- helpers --- + + private static String buildGoWorkJson(String... diskPaths) { + StringBuilder sb = new StringBuilder("{\"Go\": \"1.22\", \"Use\": ["); + for (int i = 0; i < diskPaths.length; i++) { + if (i > 0) sb.append(", "); + sb.append("{\"DiskPath\": \"").append(diskPaths[i]).append("\"}"); + } + sb.append("]}"); + return sb.toString(); + } + + private static void mockGoOperations( + MockedStatic mockOps, Path workspaceDir, String goWorkJson) { + mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go"); + mockOps + .when( + () -> + Operations.runProcessGetFullOutput(eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(goWorkJson, "", 0)); + } +} diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work new file mode 100644 index 00000000..fd2b5974 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./module-a + ./module-b +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod new file mode 100644 index 00000000..9d0ee8b3 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod @@ -0,0 +1,3 @@ +module example.com/module-a + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod new file mode 100644 index 00000000..670b00fd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod @@ -0,0 +1,3 @@ +module example.com/module-b + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod new file mode 100644 index 00000000..7fe9faa2 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod @@ -0,0 +1,3 @@ +module example.com/existing + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work new file mode 100644 index 00000000..5eaa2341 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./existing + ./nonexistent +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work new file mode 100644 index 00000000..50fc223c --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./libs/core + ./libs/util +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod new file mode 100644 index 00000000..2bf082f2 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/core + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod new file mode 100644 index 00000000..3954c84d --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/util + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work new file mode 100644 index 00000000..241141be --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work @@ -0,0 +1,3 @@ +go 1.22 + +use ./mymod diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod new file mode 100644 index 00000000..f8019326 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod @@ -0,0 +1,3 @@ +module example.com/mymod + +go 1.22