From c03a8e19fab264d76380ffc7e01ed2bc999d8f66 Mon Sep 17 00:00:00 2001 From: Chao Wang Date: Wed, 8 Apr 2026 11:38:14 +0800 Subject: [PATCH 1/3] feat(api): add generateSbom API method and SBOM CLI command Add a new public generateSbom(String manifestFile) method to the Api interface and its ExhortApi implementation. The method generates a CycloneDX SBOM from a manifest file locally using the existing provider infrastructure, without sending anything to the backend. Add a new 'sbom' CLI command with an --output flag. Without --output, the SBOM JSON is printed to stdout. With --output , it is written to the specified file. Implements TC-3991 Assisted-by: Claude Code --- README.md | 21 ++++- .../io/github/guacsec/trustifyda/Api.java | 9 ++ .../io/github/guacsec/trustifyda/cli/App.java | 38 +++++++- .../guacsec/trustifyda/cli/CliArgs.java | 11 +++ .../guacsec/trustifyda/cli/Command.java | 3 +- .../guacsec/trustifyda/impl/ExhortApi.java | 8 ++ src/main/resources/cli_help.txt | 10 ++ .../guacsec/trustifyda/cli/AppTest.java | 93 ++++++++++++++++++- .../trustifyda/impl/Exhort_Api_Test.java | 93 +++++++++++++++++++ 9 files changed, 281 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7602e3ef..b4e86e6f 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ public class TrustifyExample { var result = componentWithLicense.get(); var report = result.report(); // standard AnalysisReport var licenseSummary = result.licenseSummary(); // license compatibility summary (may be null) + + // generate a CycloneDX SBOM locally (no backend call required) + String sbomJson = exhortApi.generateSbom("/path/to/pom.xml"); } } ``` @@ -670,6 +673,16 @@ java -jar trustify-da-java-client-cli.jar license ``` Display project license information from manifest and LICENSE file in JSON format. +**SBOM Generation** +```shell +java -jar trustify-da-java-client-cli.jar sbom [--output ] +``` +Generate a CycloneDX SBOM from the specified manifest file locally, without sending anything to the backend. + +Options: +- `--output ` - Write SBOM JSON to the specified file +- (default) - Print SBOM JSON to stdout + **Image Analysis** ```shell java -jar trustify-da-java-client-cli.jar image [...] [--summary|--html] @@ -690,9 +703,9 @@ Options: The client requires the backend URL to be configured through the environment variable: -- **Environment variable**: `TRUSTIFY_DA_BACKEND_URL=https://backend.url` (required) +- **Environment variable**: `TRUSTIFY_DA_BACKEND_URL=https://backend.url` (required for analysis commands) -The application will fail to start if this environment variable is not set. +The application will fail to start if this environment variable is not set, except for the `sbom` command which operates locally without a backend connection. #### Examples @@ -720,6 +733,10 @@ java -jar trustify-da-java-client-cli.jar component /path/to/go.mod --summary # Rust Cargo analysis java -jar trustify-da-java-client-cli.jar stack /path/to/Cargo.toml --summary +# SBOM generation (no backend required) +java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml +java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json + # License information java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml diff --git a/src/main/java/io/github/guacsec/trustifyda/Api.java b/src/main/java/io/github/guacsec/trustifyda/Api.java index fbfb704c..18abb052 100644 --- a/src/main/java/io/github/guacsec/trustifyda/Api.java +++ b/src/main/java/io/github/guacsec/trustifyda/Api.java @@ -124,4 +124,13 @@ CompletableFuture> imageAnalysis(Set ima throws IOException; CompletableFuture imageAnalysisHtml(Set imageRefs) throws IOException; + + /** + * Generate a CycloneDX SBOM from a manifest file locally without sending anything to the backend. + * + * @param manifestFile the path for the manifest file + * @return the CycloneDX JSON SBOM content as a String + * @throws IOException when failed to load the manifest file + */ + String generateSbom(String manifestFile) throws IOException; } diff --git a/src/main/java/io/github/guacsec/trustifyda/cli/App.java b/src/main/java/io/github/guacsec/trustifyda/cli/App.java index b9051a69..080e12e8 100644 --- a/src/main/java/io/github/guacsec/trustifyda/cli/App.java +++ b/src/main/java/io/github/guacsec/trustifyda/cli/App.java @@ -94,6 +94,7 @@ private static CliArgs parseArgs(String[] args) { return switch (command) { case STACK, COMPONENT, LICENSE -> parseFileBasedArgs(command, args); case IMAGE -> parseImageBasedArgs(command, args); + case SBOM -> parseSbomArgs(args); }; } @@ -153,17 +154,40 @@ private static CliArgs parseImageBasedArgs(Command command, String[] args) { return new CliArgs(command, imageRefs, outputFormat); } + private static CliArgs parseSbomArgs(String[] args) { + if (args.length < 2) { + throw new IllegalArgumentException("Missing required file path for sbom command"); + } + + Path path = validateFile(args[1]); + Path outputPath = null; + + for (int i = 2; i < args.length; i++) { + if ("--output".equals(args[i])) { + if (i + 1 >= args.length) { + throw new IllegalArgumentException("Missing value for --output flag"); + } + outputPath = Paths.get(args[++i]); + } else { + throw new IllegalArgumentException("Unknown option for sbom command: " + args[i]); + } + } + + return new CliArgs(Command.SBOM, path, outputPath); + } + private static Command parseCommand(String commandStr) { return switch (commandStr) { case "stack" -> Command.STACK; case "component" -> Command.COMPONENT; case "image" -> Command.IMAGE; case "license" -> Command.LICENSE; + case "sbom" -> Command.SBOM; default -> throw new IllegalArgumentException( "Unknown command: " + commandStr - + ". Use 'stack', 'component', 'image', or 'license'"); + + ". Use 'stack', 'component', 'image', 'license', or 'sbom'"); }; } @@ -202,6 +226,7 @@ private static CompletableFuture executeCommand(CliArgs args) throws IOE executeComponentAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat); case IMAGE -> executeImageAnalysis(args.imageRefs, args.outputFormat); case LICENSE -> executeLicenseCheck(args.filePath.toAbsolutePath()); + case SBOM -> executeSbomGeneration(args); }; } @@ -300,6 +325,17 @@ private static CompletableFuture executeImageAnalysis( }; } + private static CompletableFuture executeSbomGeneration(CliArgs args) throws IOException { + Api api = new ExhortApi(); + String sbomJson = api.generateSbom(args.filePath.toAbsolutePath().toString()); + if (args.outputPath != null) { + Files.writeString(args.outputPath, sbomJson); + return CompletableFuture.completedFuture( + "SBOM written to " + args.outputPath.toAbsolutePath()); + } + return CompletableFuture.completedFuture(sbomJson); + } + private static String formatImageAnalysisResult(Map analysisResults) { try { return MAPPER.writeValueAsString(analysisResults); diff --git a/src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java b/src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java index 92da528e..1c72baae 100644 --- a/src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java +++ b/src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java @@ -25,12 +25,14 @@ public class CliArgs { public final Path filePath; public final Set imageRefs; public final OutputFormat outputFormat; + public final Path outputPath; public CliArgs(Command command, Path filePath, OutputFormat outputFormat) { this.command = command; this.filePath = filePath; this.imageRefs = null; this.outputFormat = outputFormat; + this.outputPath = null; } public CliArgs(Command command, Set imageRefs, OutputFormat outputFormat) { @@ -38,5 +40,14 @@ public CliArgs(Command command, Set imageRefs, OutputFormat outputForm this.filePath = null; this.imageRefs = imageRefs; this.outputFormat = outputFormat; + this.outputPath = null; + } + + public CliArgs(Command command, Path filePath, Path outputPath) { + this.command = command; + this.filePath = filePath; + this.imageRefs = null; + this.outputFormat = null; + this.outputPath = outputPath; } } diff --git a/src/main/java/io/github/guacsec/trustifyda/cli/Command.java b/src/main/java/io/github/guacsec/trustifyda/cli/Command.java index 56d188a1..745602fc 100644 --- a/src/main/java/io/github/guacsec/trustifyda/cli/Command.java +++ b/src/main/java/io/github/guacsec/trustifyda/cli/Command.java @@ -20,5 +20,6 @@ public enum Command { STACK, COMPONENT, IMAGE, - LICENSE + LICENSE, + SBOM } 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 bf9a1986..d06a20b8 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -438,6 +438,14 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType return buildRequest(content, uri, acceptType, "Stack Analysis"); } + @Override + public String generateSbom(final String manifestFile) throws IOException { + var manifestPath = Path.of(manifestFile); + var provider = Ecosystem.getProvider(manifestPath); + var content = provider.provideStack(); + return new String(content.buffer, java.nio.charset.StandardCharsets.UTF_8); + } + @Override public CompletableFuture> imageAnalysis( final Set imageRefs) throws IOException { diff --git a/src/main/resources/cli_help.txt b/src/main/resources/cli_help.txt index 775576e2..d4fb039b 100644 --- a/src/main/resources/cli_help.txt +++ b/src/main/resources/cli_help.txt @@ -18,6 +18,12 @@ COMMANDS: --summary Output summary in JSON format (without license check) (default) Output full report in JSON format (includes license summary) + sbom [--output ] + Generate a CycloneDX SBOM from the specified manifest file (no backend call) + Options: + --output Write SBOM JSON to the specified file + (default) Print SBOM JSON to stdout + license Display project license information from manifest and LICENSE file in JSON format @@ -49,6 +55,10 @@ EXAMPLES: java -jar trustify-da-java-client-cli.jar component /path/to/requirements.txt java -jar trustify-da-java-client-cli.jar stack /path/to/Cargo.toml + # SBOM generation + java -jar trustify-da-java-client-cli.jar sbom /path/to/pom.xml + java -jar trustify-da-java-client-cli.jar sbom /path/to/package.json --output sbom.json + # License information java -jar trustify-da-java-client-cli.jar license /path/to/pom.xml diff --git a/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java b/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java index 42d21384..c13c363e 100644 --- a/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java @@ -404,11 +404,13 @@ void command_enum_should_have_correct_values() { assertThat(Command.COMPONENT).isNotNull(); assertThat(Command.IMAGE).isNotNull(); assertThat(Command.LICENSE).isNotNull(); - assertThat(Command.values()).hasSize(4); + assertThat(Command.SBOM).isNotNull(); + assertThat(Command.values()).hasSize(5); assertThat(Command.valueOf("STACK")).isEqualTo(Command.STACK); assertThat(Command.valueOf("COMPONENT")).isEqualTo(Command.COMPONENT); assertThat(Command.valueOf("IMAGE")).isEqualTo(Command.IMAGE); assertThat(Command.valueOf("LICENSE")).isEqualTo(Command.LICENSE); + assertThat(Command.valueOf("SBOM")).isEqualTo(Command.SBOM); } @Test @@ -1013,4 +1015,93 @@ private AnalysisReport defaultAnalysisReport() { .putSourcesItem("osv", new Source().summary(new SourceSummary()))); return report; } + + @Test + void executeCommand_with_sbom_should_return_sbom_json() throws Exception { + CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, (Path) null); + + var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}"; + + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom); + })) { + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + assertThat(result).isNotNull(); + assertThat(result.get()).isEqualTo(fakeSbom); + } + } + + @Test + void executeCommand_with_sbom_and_output_should_write_to_file() throws Exception { + var outputFile = java.nio.file.Files.createTempFile("sbom_output_", ".json"); + java.nio.file.Files.deleteIfExists(outputFile); + + CliArgs args = new CliArgs(Command.SBOM, TEST_FILE, outputFile); + + var fakeSbom = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}"; + + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.generateSbom(any(String.class))).thenReturn(fakeSbom); + })) { + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + assertThat(result).isNotNull(); + assertThat(result.get()).contains("SBOM written to"); + assertThat(java.nio.file.Files.readString(outputFile)).isEqualTo(fakeSbom); + } finally { + java.nio.file.Files.deleteIfExists(outputFile); + } + } + + @Test + void executeCommand_with_sbom_and_unsupported_file_should_throw_exception() throws Exception { + var tmpFile = java.nio.file.Files.createTempFile("unsupported_", ".xyz"); + + CliArgs args = new CliArgs(Command.SBOM, tmpFile, (Path) null); + + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.generateSbom(any(String.class))) + .thenThrow(new IllegalStateException("Unknown manifest file unsupported_.xyz")); + })) { + + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + assertThatThrownBy(() -> executeCommandMethod.invoke(null, args)) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unknown manifest file"); + } finally { + java.nio.file.Files.deleteIfExists(tmpFile); + } + } + + @Test + void parseCommand_with_sbom_should_return_sbom_command() throws Exception { + Method parseCommandMethod = App.class.getDeclaredMethod("parseCommand", String.class); + parseCommandMethod.setAccessible(true); + + Command result = (Command) parseCommandMethod.invoke(null, "sbom"); + assertThat(result).isEqualTo(Command.SBOM); + } } diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java index 9a6bf4ae..0ce0bca2 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java @@ -824,4 +824,97 @@ void test_create_httpclient_should_fallback_to_direct_when_proxyurl_is_invalid() "When proxy url is malformed, createHttpClient() should not set a ProxySelector and should" + " fall back to direct connections"); } + + @Test + void generateSbom_with_valid_pom_xml_should_return_cyclonedx_json() throws IOException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); + try (var is = + getResourceAsStreamDecision(this.getClass(), "tst_manifests/maven/empty/pom.xml")) { + Files.write(tmpFile, is.readAllBytes()); + } + + var fakeSbomContent = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.4\",\"components\":[]}"; + given(mockProvider.provideStack()) + .willReturn( + new Provider.Content(fakeSbomContent.getBytes(), "application/vnd.cyclonedx+json")); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool.when(() -> Ecosystem.getProvider(tmpFile)).thenReturn(mockProvider); + + var result = exhortApiSut.generateSbom(tmpFile.toString()); + + then(result).isEqualTo(fakeSbomContent); + then(result).contains("CycloneDX"); + } + + Files.deleteIfExists(tmpFile); + } + + @Test + void generateSbom_with_unsupported_file_should_throw_exception() throws IOException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_", ".unsupported"); + Files.writeString(tmpFile, "dummy content"); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool + .when(() -> Ecosystem.getProvider(tmpFile)) + .thenThrow(new IllegalStateException("Unknown manifest file")); + + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, () -> exhortApiSut.generateSbom(tmpFile.toString())); + } + + Files.deleteIfExists(tmpFile); + } + + @Test + void generateSbom_should_contain_metadata_component() throws IOException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); + try (var is = + getResourceAsStreamDecision(this.getClass(), "tst_manifests/maven/empty/pom.xml")) { + Files.write(tmpFile, is.readAllBytes()); + } + + var fakeSbomContent = + "{\"bomFormat\":\"CycloneDX\",\"metadata\":{\"component\":{\"purl\":\"pkg:maven/com.example/test@1.0\"}},\"components\":[]}"; + given(mockProvider.provideStack()) + .willReturn( + new Provider.Content(fakeSbomContent.getBytes(), "application/vnd.cyclonedx+json")); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool.when(() -> Ecosystem.getProvider(tmpFile)).thenReturn(mockProvider); + + var result = exhortApiSut.generateSbom(tmpFile.toString()); + + then(result).contains("metadata"); + then(result).contains("component"); + then(result).contains("purl"); + } + + Files.deleteIfExists(tmpFile); + } + + @Test + void generateSbom_should_not_make_http_calls() throws IOException { + var tmpFile = Files.createTempFile("TRUSTIFY_DA_test_pom_", ".xml"); + try (var is = + getResourceAsStreamDecision(this.getClass(), "tst_manifests/maven/empty/pom.xml")) { + Files.write(tmpFile, is.readAllBytes()); + } + + var fakeSbomContent = "{\"bomFormat\":\"CycloneDX\",\"components\":[]}"; + given(mockProvider.provideStack()) + .willReturn( + new Provider.Content(fakeSbomContent.getBytes(), "application/vnd.cyclonedx+json")); + + try (var ecosystemTool = mockStatic(Ecosystem.class)) { + ecosystemTool.when(() -> Ecosystem.getProvider(tmpFile)).thenReturn(mockProvider); + + exhortApiSut.generateSbom(tmpFile.toString()); + + Mockito.verifyNoInteractions(mockHttpClient); + } + + Files.deleteIfExists(tmpFile); + } } From 3edf7c18839336be8aa615063209563883284cde Mon Sep 17 00:00:00 2001 From: Chao Wang Date: Wed, 8 Apr 2026 12:00:17 +0800 Subject: [PATCH 2/3] fix: make backend URL validation lazy so generateSbom works without it ExhortApi constructor no longer validates TRUSTIFY_DA_BACKEND_URL eagerly. The endpoint is resolved lazily on first use (when an HTTP call is about to be made). This allows generateSbom() and the CLI 'sbom' command to operate without a backend URL configured, since they only perform local SBOM generation. Implements TC-3991 Assisted-by: Claude Code --- .../guacsec/trustifyda/impl/ExhortApi.java | 26 ++++++++++++------- .../trustifyda/impl/Exhort_Api_Test.java | 4 ++- 2 files changed, 19 insertions(+), 11 deletions(-) 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 d06a20b8..83d6e529 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -83,10 +83,10 @@ public final class ExhortApi implements Api { public static final String S_API_V5_LICENSES_IDENTIFY = "%s/api/v5/licenses/identify"; private static final String TRUSTIFY_DA_LICENSE_CHECK = "TRUSTIFY_DA_LICENSE_CHECK"; - private final String endpoint; + private String endpoint; public String getEndpoint() { - return endpoint; + return getOrResolveEndpoint(); } private final HttpClient client; @@ -117,7 +117,6 @@ static HttpClient.Version getHttpVersion() { commonHookBeginning(true); this.client = client; this.mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - this.endpoint = getExhortUrl(); } public static HttpClient createHttpClient() { @@ -163,6 +162,13 @@ private static String getClientRequestId() { return RequestManager.getInstance().getTraceIdOfRequest(); } + private String getOrResolveEndpoint() { + if (this.endpoint == null) { + this.endpoint = getExhortUrl(); + } + return this.endpoint; + } + private String getExhortUrl() { String endpoint = Environment.get(TRUSTIFY_DA_BACKEND_URL); if (endpoint == null || endpoint.trim().isEmpty()) { @@ -346,7 +352,7 @@ public CompletableFuture componentAnalysis( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifest); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint)); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); return getAnalysisReportForComponent(uri, content, exClientTraceId); @@ -390,7 +396,7 @@ public CompletableFuture componentAnalysis(String manifestFile) String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint)); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); return getAnalysisReportForComponent(uri, content, exClientTraceId); @@ -431,7 +437,7 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType throws IOException { var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint)); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); var content = provider.provideStack(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); @@ -518,7 +524,7 @@ CompletableFuture performBatchAnalysis( final String analysisName) throws IOException { String exClientTraceId = commonHookBeginning(false); - var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, this.endpoint)); + var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getOrResolveEndpoint())); var sboms = sbomsGenerator.get(); var content = new Provider.Content( @@ -580,7 +586,7 @@ public CompletableFuture componentAnalysisWithLicense( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, this.endpoint)); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); var content = provider.provideComponent(); String sbomJson = new String(content.buffer); commonHookAfterProviderCreatedSbomAndBeforeExhort(); @@ -611,7 +617,7 @@ public CompletableFuture componentAnalysisWithLicense( */ public CompletableFuture getLicenseDetails(String spdxId) { String encodedId = URLEncoder.encode(spdxId, StandardCharsets.UTF_8).replace("+", "%20"); - URI uri = URI.create(String.format(S_API_V5_LICENSES, this.endpoint, encodedId)); + URI uri = URI.create(String.format(S_API_V5_LICENSES, getOrResolveEndpoint(), encodedId)); HttpRequest request = buildGetRequest(uri, "License Details"); return this.client @@ -657,7 +663,7 @@ public CompletableFuture identifyLicense(Path licenseFilePath) { LOG.warning(String.format("Failed to read license file: %s", e.getMessage())); return CompletableFuture.completedFuture(null); } - URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, this.endpoint)); + URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, getOrResolveEndpoint())); HttpRequest request = buildPostRequest( uri, diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java index 0ce0bca2..66976bd7 100644 --- a/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java @@ -413,9 +413,11 @@ void componentAnalysis_with_pom_xml_as_path_should_return_json_object_from_the_b @Test @ClearSystemProperty(key = "TRUSTIFY_DA_BACKEND_URL") void check_TRUSTIFY_DA_Url_Throws_Exception_When_Not_Set() { + // Backend URL validation is lazy — construction succeeds, but accessing the endpoint throws + ExhortApi api = new ExhortApi(); IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows( - IllegalStateException.class, () -> new ExhortApi()); + IllegalStateException.class, api::getEndpoint); then(exception.getMessage()) .isEqualTo( "Backend URL not configured. Please set the TRUSTIFY_DA_BACKEND_URL environment" From a2dc71b922f0aa4ecff56b89efa294f6580d23fd Mon Sep 17 00:00:00 2001 From: Chao Wang Date: Wed, 8 Apr 2026 21:11:51 +0800 Subject: [PATCH 3/3] fix: inline lazy endpoint resolution and document IllegalStateException Co-Authored-By: Claude Opus 4.6 (1M context) --- .../io/github/guacsec/trustifyda/Api.java | 1 + .../guacsec/trustifyda/impl/ExhortApi.java | 26 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/main/java/io/github/guacsec/trustifyda/Api.java b/src/main/java/io/github/guacsec/trustifyda/Api.java index 18abb052..947dbb3e 100644 --- a/src/main/java/io/github/guacsec/trustifyda/Api.java +++ b/src/main/java/io/github/guacsec/trustifyda/Api.java @@ -131,6 +131,7 @@ CompletableFuture> imageAnalysis(Set ima * @param manifestFile the path for the manifest file * @return the CycloneDX JSON SBOM content as a String * @throws IOException when failed to load the manifest file + * @throws IllegalStateException when the manifest file type is not supported */ String generateSbom(String manifestFile) throws IOException; } 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 83d6e529..a088294c 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -86,7 +86,10 @@ public final class ExhortApi implements Api { private String endpoint; public String getEndpoint() { - return getOrResolveEndpoint(); + if (this.endpoint == null) { + this.endpoint = getExhortUrl(); + } + return this.endpoint; } private final HttpClient client; @@ -162,13 +165,6 @@ private static String getClientRequestId() { return RequestManager.getInstance().getTraceIdOfRequest(); } - private String getOrResolveEndpoint() { - if (this.endpoint == null) { - this.endpoint = getExhortUrl(); - } - return this.endpoint; - } - private String getExhortUrl() { String endpoint = Environment.get(TRUSTIFY_DA_BACKEND_URL); if (endpoint == null || endpoint.trim().isEmpty()) { @@ -352,7 +348,7 @@ public CompletableFuture componentAnalysis( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifest); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint())); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); return getAnalysisReportForComponent(uri, content, exClientTraceId); @@ -396,7 +392,7 @@ public CompletableFuture componentAnalysis(String manifestFile) String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint())); var content = provider.provideComponent(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); return getAnalysisReportForComponent(uri, content, exClientTraceId); @@ -437,7 +433,7 @@ private HttpRequest buildStackRequest(final String manifestFile, final MediaType throws IOException { var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint())); var content = provider.provideStack(); commonHookAfterProviderCreatedSbomAndBeforeExhort(); @@ -524,7 +520,7 @@ CompletableFuture performBatchAnalysis( final String analysisName) throws IOException { String exClientTraceId = commonHookBeginning(false); - var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getOrResolveEndpoint())); + var uri = URI.create(String.format(S_API_V_5_BATCH_ANALYSIS, getEndpoint())); var sboms = sbomsGenerator.get(); var content = new Provider.Content( @@ -586,7 +582,7 @@ public CompletableFuture componentAnalysisWithLicense( String exClientTraceId = commonHookBeginning(false); var manifestPath = Path.of(manifestFile); var provider = Ecosystem.getProvider(manifestPath); - var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getOrResolveEndpoint())); + var uri = URI.create(String.format(S_API_V_5_ANALYSIS, getEndpoint())); var content = provider.provideComponent(); String sbomJson = new String(content.buffer); commonHookAfterProviderCreatedSbomAndBeforeExhort(); @@ -617,7 +613,7 @@ public CompletableFuture componentAnalysisWithLicense( */ public CompletableFuture getLicenseDetails(String spdxId) { String encodedId = URLEncoder.encode(spdxId, StandardCharsets.UTF_8).replace("+", "%20"); - URI uri = URI.create(String.format(S_API_V5_LICENSES, getOrResolveEndpoint(), encodedId)); + URI uri = URI.create(String.format(S_API_V5_LICENSES, getEndpoint(), encodedId)); HttpRequest request = buildGetRequest(uri, "License Details"); return this.client @@ -663,7 +659,7 @@ public CompletableFuture identifyLicense(Path licenseFilePath) { LOG.warning(String.format("Failed to read license file: %s", e.getMessage())); return CompletableFuture.completedFuture(null); } - URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, getOrResolveEndpoint())); + URI uri = URI.create(String.format(S_API_V5_LICENSES_IDENTIFY, getEndpoint())); HttpRequest request = buildPostRequest( uri,