Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -875,6 +876,14 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
return discoverCargoManifests(workspaceDir, ignorePatterns);
}

// Go workspace: go.work
if (Files.isRegularFile(workspaceDir.resolve("go.work"))) {
List<Path> 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 =
Expand Down Expand Up @@ -930,6 +939,43 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
}
}

/**
* Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get
* workspace members.
*/
private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> 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<Path> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UseEntry> use) {

@JsonIgnoreProperties(ignoreUnknown = true)
public record UseEntry(@JsonProperty("DiskPath") String diskPath) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,17 @@ public static List<Path> filterByIgnorePatterns(

List<PathMatcher> matchers =
ignorePatterns.stream()
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
.flatMap(
p -> {
var fs = FileSystems.getDefault();
java.util.stream.Stream.Builder<PathMatcher> 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()
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> 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<Path> 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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> mockOps = Mockito.mockStatic(Operations.class)) {
mockGoOperations(mockOps, workspaceDir, goWorkJson);

ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class));
List<Path> 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<Operations> 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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
go 1.22

use (
./module-a
./module-b
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module example.com/module-a

go 1.22
Loading
Loading