Skip to content

feat(api): add generateSbom API method and SBOM CLI command#396

Merged
soul2zimate merged 4 commits into
guacsec:mainfrom
soul2zimate:TC-3991
Apr 8, 2026
Merged

feat(api): add generateSbom API method and SBOM CLI command#396
soul2zimate merged 4 commits into
guacsec:mainfrom
soul2zimate:TC-3991

Conversation

@soul2zimate

Copy link
Copy Markdown
Contributor

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

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 <path>, it is written
to the specified file.

Implements TC-3991

Assisted-by: Claude Code
@qodo-code-review

Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add generateSbom API method and SBOM CLI command

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add generateSbom() API method for local SBOM generation
• Implement new sbom CLI command with optional --output flag
• Support CycloneDX SBOM generation without backend calls
• Add comprehensive tests and documentation for SBOM functionality
Diagram
flowchart LR
  A["Manifest File"] -->|"generateSbom()"| B["ExhortApi"]
  B -->|"getProvider()"| C["Ecosystem Provider"]
  C -->|"provideStack()"| D["CycloneDX SBOM JSON"]
  D -->|"--output flag"| E["Write to File"]
  D -->|"no flag"| F["Print to stdout"]
Loading

Grey Divider

File Changes

1. src/main/java/io/github/guacsec/trustifyda/Api.java ✨ Enhancement +9/-0

Add generateSbom method to API interface

src/main/java/io/github/guacsec/trustifyda/Api.java


2. src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java ✨ Enhancement +8/-0

Implement generateSbom using provider infrastructure

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java


3. src/main/java/io/github/guacsec/trustifyda/cli/Command.java ✨ Enhancement +2/-1

Add SBOM command enum value

src/main/java/io/github/guacsec/trustifyda/cli/Command.java


View more (6)
4. src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java ✨ Enhancement +11/-0

Add outputPath field and constructor for SBOM args

src/main/java/io/github/guacsec/trustifyda/cli/CliArgs.java


5. src/main/java/io/github/guacsec/trustifyda/cli/App.java ✨ Enhancement +37/-1

Add SBOM command parsing and execution logic

src/main/java/io/github/guacsec/trustifyda/cli/App.java


6. src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java 🧪 Tests +92/-1

Add SBOM command tests and enum validation

src/test/java/io/github/guacsec/trustifyda/cli/AppTest.java


7. src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java 🧪 Tests +93/-0

Add comprehensive generateSbom method tests

src/test/java/io/github/guacsec/trustifyda/impl/Exhort_Api_Test.java


8. README.md 📝 Documentation +19/-2

Document SBOM generation API and CLI usage

README.md


9. src/main/resources/cli_help.txt 📝 Documentation +10/-0

Add SBOM command help and usage examples

src/main/resources/cli_help.txt


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1)   📘 Rule violations (0)   📎 Requirement gaps (0)   🎨 UX Issues (0)
🐞\ ≡ Correctness (1)

Grey Divider


Action required

1. SBOM needs backend URL🐞
Description
sbom command and Api.generateSbom() both construct new ExhortApi(), but ExhortApi always
validates TRUSTIFY_DA_BACKEND_URL in its constructor and throws if it’s missing, so SBOM
generation fails even though it performs no HTTP calls.
Code

src/main/java/io/github/guacsec/trustifyda/cli/App.java[R328-336]

+  private static CompletableFuture<String> 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);
Evidence
The new SBOM CLI path constructs new ExhortApi() before calling generateSbom(). ExhortApi
construction always calls getExhortUrl(), which throws IllegalStateException when
TRUSTIFY_DA_BACKEND_URL is unset. This contradicts the README’s statement that sbom operates
without a backend connection.

src/main/java/io/github/guacsec/trustifyda/cli/App.java[328-336]
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[99-121]
src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[166-172]
README.md[702-709]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The new local SBOM generation flow still requires `TRUSTIFY_DA_BACKEND_URL` because `new ExhortApi()` validates the backend URL during construction, even though `generateSbom()` itself doesn’t use the endpoint.

### Issue Context
The CLI and the new public API method are documented as operating locally without a backend connection. However, instantiating `ExhortApi` currently throws when the backend URL is not configured.

### Fix Focus Areas
- src/main/java/io/github/guacsec/trustifyda/cli/App.java[328-336]
- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[99-121]
- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[166-182]

### Implementation direction
- Make backend URL validation lazy (only when a backend call is about to be made), OR
- Provide a construction path for local-only operations (e.g., a constructor/factory that skips endpoint initialization), and use it for `sbom`, OR
- Avoid `ExhortApi` entirely for SBOM generation in the CLI by calling `Ecosystem.getProvider(path).provideStack()` directly (and keep `Api.generateSbom()` usable without requiring endpoint configuration).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. SBOM charset may corrupt 🐞
Description
generateSbom() decodes provider output bytes as UTF-8, but multiple providers create those bytes
using String.getBytes() without a charset (platform default), which can corrupt non-ASCII SBOM
content on non-UTF-8 platforms.
Code

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[R441-447]

+  @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);
+  }
Evidence
ExhortApi.generateSbom() forces UTF‑8 decoding. However, several Provider.provideStack()
implementations build content.buffer from sbom.getAsJsonString().getBytes() (no charset), which
uses the platform default charset; encode/decode mismatch can produce invalid JSON when SBOM
contains non‑ASCII characters (e.g., license text/name fields).

src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[441-447]
src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java[169-173]
src/main/java/io/github/guacsec/trustifyda/providers/CargoProvider.java[677-687]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`generateSbom()` assumes the provider buffer is UTF‑8, but some providers encode SBOM JSON using the platform default charset.

### Issue Context
Providers return `Provider.Content` as bytes; the new API converts those bytes back to a `String` using UTF‑8.

### Fix Focus Areas
- src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java[441-447]
- src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java[169-173]
- src/main/java/io/github/guacsec/trustifyda/providers/CargoProvider.java[677-687]

### Implementation direction
- Update providers that use `getBytes()` without charset to use `getBytes(StandardCharsets.UTF_8)` so the buffer is unambiguously UTF‑8.
- (Optional) Keep `generateSbom()` decoding as UTF‑8 to match CycloneDX JSON expectations.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread src/main/java/io/github/guacsec/trustifyda/cli/App.java
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
@soul2zimate

Copy link
Copy Markdown
Contributor Author

/review

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Thread Safety

The new lazy initialization of the backend endpoint caches the resolved URL in a mutable instance field without synchronization/volatile, which can lead to race conditions or redundant resolution under concurrent use. Consider making endpoint resolution thread-safe (e.g., volatile + double-check, synchronized, or initialize once in constructor for backend-dependent commands while keeping sbom local).

private String endpoint;

public String getEndpoint() {
  return getOrResolveEndpoint();
}

private final HttpClient client;
private final ObjectMapper mapper;

private LocalDateTime startTime;
private LocalDateTime providerEndTime;
private LocalDateTime endTime;

public ExhortApi() {
  this(createHttpClient());
}

/**
 * Get the HTTP protocol Version set by client in environment variable, if not set, the default is
 * HTTP Protocol Version 1.1
 *
 * @return i.e. HttpClient.Version.HTTP_1.1
 */
static HttpClient.Version getHttpVersion() {
  var version = Environment.get(HTTP_VERSION_TRUSTIFY_DA_CLIENT);
  return (version != null && version.contains("2"))
      ? HttpClient.Version.HTTP_2
      : HttpClient.Version.HTTP_1_1;
}

ExhortApi(final HttpClient client) {
  commonHookBeginning(true);
  this.client = client;
  this.mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

public static HttpClient createHttpClient() {
  HttpClient.Builder builder = HttpClient.newBuilder().version(getHttpVersion());
  String proxyUrl = Environment.get(TRUSTIFY_DA_PROXY_URL);
  if (proxyUrl != null && !proxyUrl.isBlank()) {
    try {
      URI proxyUri = URI.create(proxyUrl);
      builder.proxy(
          ProxySelector.of(new InetSocketAddress(proxyUri.getHost(), proxyUri.getPort())));
    } catch (IllegalArgumentException e) {
      LOG.warning("Invalid TRUSTIFY_DA_PROXY_URL: " + proxyUrl + ", using direct connection");
    }
  }
  return builder.build();
}

private String commonHookBeginning(boolean startOfApi) {
  if (startOfApi) {
    if (debugLoggingIsNeeded()) {
      LOG.info("Start of trustify-da-java-client");
      LOG.info(String.format("Starting time of API: %s", LocalDateTime.now()));
    }
  } else {
    if (Objects.isNull(getClientRequestId())) {
      generateClientRequestId();
    }
    if (debugLoggingIsNeeded()) {

      this.startTime = LocalDateTime.now();

      LOG.info(String.format("Starting time: %s", this.startTime));
    }
  }
  return getClientRequestId();
}

private static void generateClientRequestId() {
  RequestManager.getInstance().addClientTraceIdToRequest(UUID.randomUUID().toString());
}

private static String getClientRequestId() {
  return RequestManager.getInstance().getTraceIdOfRequest();
}

private String getOrResolveEndpoint() {
  if (this.endpoint == null) {
    this.endpoint = getExhortUrl();
  }
  return this.endpoint;
}
File Output Handling

SBOM output writing uses Files.writeString directly to the provided path; consider specifying a charset explicitly, ensuring parent directories exist, and defining behavior for overwriting vs failing if the file exists. Also consider surfacing a clearer user-facing error message when writing fails.

private static CompletableFuture<String> 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);
}
API Consistency

The new generateSbom API returns a String while other APIs typically return CompletableFuture (async) or bytes for HTML; confirm this synchronous choice is intentional and consistent with expected usage patterns and IO behavior (e.g., large SBOMs).

/**
 * 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;

@soul2zimate

Copy link
Copy Markdown
Contributor Author

Verification Report for TC-3991 (commit 3edf7c1)

Check Result Details
Review Feedback PASS 1 code change request (qodo bot: backend URL required for SBOM), addressed in commit 3edf7c1
Root-Cause Investigation N/A No sub-tasks created
Scope Containment WARN 3 out-of-scope files: README.md (documentation update), cli_help.txt (help text), AppTest.java (fix broken enum size test) — all justified
Diff Size PASS 300+/16- across 9 files — proportionate to scope
Commit Traceability PASS Both commits reference "Implements TC-3991"
Sensitive Patterns PASS No sensitive patterns detected
CI Status WARN 1 failure (ubuntu/syft) is infrastructure — HTTP 502 downloading syft binary. All other 37 checks pass.
Acceptance Criteria PASS 9/9 criteria met
Verification Commands N/A Not run in this verification

Overall: WARN

Issues noted (non-blocking):

  1. Scope (WARN): README.md, cli_help.txt, and AppTest.java are out-of-scope per task description but are justified changes (documentation updates and fixing a pre-existing test broken by the new enum value).
  2. CI (WARN): ubuntu/syft failure is a transient infrastructure issue (HTTP 502 downloading syft binary from GitHub releases, checksum mismatch). Not related to code changes.

Acceptance Criteria Details:

  • ✓ Api.generateSbom(String) added to interface
  • ✓ ExhortApi implements using Ecosystem.getProvider() + provideStack()
  • ✓ Returns CycloneDX JSON for all 7+ supported manifest types
  • ✓ No HTTP request made (verified by Mockito.verifyNoInteractions test)
  • ✓ IOException declared for invalid files
  • ✓ CLI accepts 'sbom' command
  • ✓ Stdout output without --output flag
  • ✓ File write with --output flag
  • ✓ CLI help documents SBOM command and --output flag
  • ✓ Backend URL validation is lazy — generateSbom works without TRUSTIFY_DA_BACKEND_URL

This comment was AI-generated by sdlc-workflow/verify-pr v0.5.10.

@soul2zimate
soul2zimate requested a review from ruromero April 8, 2026 04:16

@ruromero ruromero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation is correct. Just 2 minor comments

Comment thread src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java Outdated
Comment thread src/main/java/io/github/guacsec/trustifyda/Api.java
soul2zimate and others added 2 commits April 8, 2026 21:11
@soul2zimate

Copy link
Copy Markdown
Contributor Author

thank you for the review, PR is updated.

@soul2zimate
soul2zimate requested a review from ruromero April 8, 2026 13:13

@ruromero ruromero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good, thanks!

@soul2zimate
soul2zimate merged commit 0ce390e into guacsec:main Apr 8, 2026
38 checks passed
@soul2zimate
soul2zimate deleted the TC-3991 branch April 8, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants