Repository: microsoft/sbom-tool
Pull request #1034: Avoid Exception if an unsupported format is requested
Author: login: DaveTryon
Created: 2025-05-03T00:16:14Z | Merged: 2025-05-05T19:41:05Z

If a config file is used, it is possible to pass in a request for a manifest type that we don't support. When this happens, the code throws an exception in `SbomGenerationWorkflow.GenerateAsync()`. This PR uses a `TryGet` pattern to more gracefully detect this situation and display a warning so the caller can decide to fix it if they want. It also adds code to return false if none of manifest types in the config file are supported. Unit tests cover both supported and unsupported manifest types.


Diff:
diff --git a/src/Microsoft.Sbom.Api/Workflows/SbomGenerationWorkflow.cs b/src/Microsoft.Sbom.Api/Workflows/SbomGenerationWorkflow.cs
index ccac0a619..903a1b72e 100644
--- a/src/Microsoft.Sbom.Api/Workflows/SbomGenerationWorkflow.cs
+++ b/src/Microsoft.Sbom.Api/Workflows/SbomGenerationWorkflow.cs
@@ -76,6 +76,8 @@ public virtual async Task<bool> RunAsync()
         IList<FileValidationResult> validErrors = new List<FileValidationResult>();
         string sbomDir = null;
         var deleteSbomDir = false;
+        var triedToGenerateAtLeastOneManifest = false;
+
         using (recorder.TraceEvent(Events.SbomGenerationWorkflow))
         {
             try
@@ -99,34 +101,41 @@ public virtual async Task<bool> RunAsync()
                 // Use the WriteJsonObjectsToSbomAsync method based on the SPDX version in manifest info
                 foreach (var manifestInfo in manifestInfos)
                 {
-                    var config = sbomConfigs.Get(manifestInfo);
-                    await using (sbomConfigs.StartJsonSerializationAsync(config))
+                    if (sbomConfigs.TryGet(manifestInfo, out var config))
                     {
-                        config.JsonSerializer?.StartJsonObject();
-
-                        // Get the appropriate strategy
-                        var serializationStrategy = JsonSerializationStrategyFactory.GetStrategy(manifestInfo.Version);
-                        validErrors = await serializationStrategy.WriteJsonObjectsToSbomAsync(
-                            config,
-                            manifestInfo.Version,
-                            fileArrayGenerator,
-                            packageArrayGenerator,
-                            relationshipsArrayGenerator,
-                            externalDocumentReferenceGenerator).
-                            ConfigureAwait(false);
-
-                        // Write headers
-                        serializationStrategy.AddHeadersToSbom(sbomConfigs, config);
-
-                        // Finalize JSON
-                        config.JsonSerializer?.FinalizeJsonObject();
+                        triedToGenerateAtLeastOneManifest = true;
+                        await using (sbomConfigs.StartJsonSerializationAsync(config))
+                        {
+                            config.JsonSerializer?.StartJsonObject();
+
+                            // Get the appropriate strategy
+                            var serializationStrategy = JsonSerializationStrategyFactory.GetStrategy(manifestInfo.Version);
+                            validErrors = await serializationStrategy.WriteJsonObjectsToSbomAsync(
+                                config,
+                                manifestInfo.Version,
+                                fileArrayGenerator,
+                                packageArrayGenerator,
+                                relationshipsArrayGenerator,
+                                externalDocumentReferenceGenerator).
+                                ConfigureAwait(false);
+
+                            // Write headers
+                            serializationStrategy.AddHeadersToSbom(sbomConfigs, config);
+
+                            // Finalize JSON
+                            config.JsonSerializer?.FinalizeJsonObject();
+                        }
+
+                        // Generate SHA256 for manifest json
+                        GenerateHashForManifestJson(config.ManifestJsonFilePath);
+                    }
+                    else
+                    {
+                        log.Warning($"Ignoring unregistered manifest type: {manifestInfo}");
                     }
-
-                    // Generate SHA256 for manifest json
-                    GenerateHashForManifestJson(config.ManifestJsonFilePath);
                 }
 
-                return !validErrors.Any();
+                return triedToGenerateAtLeastOneManifest && !validErrors.Any();
             }
             catch (Exception e)
             {
diff --git a/test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs b/test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
index 9701785a6..5ff087b70 100644
--- a/test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
+++ b/test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
@@ -1,6 +1,7 @@
 // Copyright (c) Microsoft. All rights reserved.
 // Licensed under the MIT license. See LICENSE file in the project root for full license information.
 
+using System;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
@@ -453,7 +454,7 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
         using var manifestStream = new MemoryStream();
 
         fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny<string>())).Returns(true);
-        var sbomConfig = new SbomConfig(fileSystemMock.Object)
+        ISbomConfig sbomConfig = new SbomConfig(fileSystemMock.Object)
         {
             ManifestInfo = Constants.TestManifestInfo,
             ManifestJsonDirPath = "/root/_manifest",
@@ -482,7 +483,7 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
         externalDocumentReferenceGeneratorMock.Setup(f => f.GenerateAsync()).ReturnsAsync(generationResult);
 
         var sbomConfigsMock = new Mock<ISbomConfigProvider>();
-        sbomConfigsMock.Setup(f => f.Get(It.IsAny<ManifestInfo>())).Returns(sbomConfig);
+        sbomConfigsMock.Setup(f => f.TryGet(It.IsAny<ManifestInfo>(), out sbomConfig)).Returns(true);
 
         var workflow = new SbomGenerationWorkflow(
             configurationMock.Object,
@@ -498,11 +499,60 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
 
         var result = await workflow.RunAsync();
 
+        Assert.IsFalse(result);
         fileArrayGeneratorMock.VerifyAll();
         packageArrayGeneratorMock.VerifyAll();
         relationshipsArrayGeneratorMock.VerifyAll();
         externalDocumentReferenceGeneratorMock.VerifyAll();
         fileSystemMock.Verify(f => f.DeleteDir(It.IsAny<string>(), true), Times.Once);
+    }
+
+    [TestMethod]
+    public async Task ManifestGenerationWorkflowTests_UnknownManifestType_LogsExpectedWarning()
+    {
+        ISbomConfig sbomConfig = null;
+
+        IList<ManifestInfo> testManifestInfo = new List<ManifestInfo> { Constants.TestManifestInfo };
+        configurationMock.SetupGet(x => x.ManifestDirPath).Returns(new ConfigurationSetting<string> { Value = PathUtils.Join("/root", "_manifest"), Source = SettingSource.CommandLine });
+        configurationMock.SetupGet(x => x.ManifestInfo).Returns(new ConfigurationSetting<IList<ManifestInfo>> { Value = testManifestInfo, Source = SettingSource.CommandLine });
+        fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny<string>())).Returns(true);
+        fileSystemMock.Setup(f => f.DeleteDir(It.IsAny<string>(), true)).Verifiable();
+
+        var warnings = new List<string>();
+        mockLogger.Setup(l => l.Warning(It.IsAny<string>()))
+            .Callback<string>(warnings.Add);
+
+        var fileArrayGeneratorMock = new Mock<IJsonArrayGenerator<FileArrayGenerator>>(MockBehavior.Strict);
+        var packageArrayGeneratorMock = new Mock<IJsonArrayGenerator<PackageArrayGenerator>>(MockBehavior.Strict);
+        var relationshipsArrayGeneratorMock = new Mock<IJsonArrayGenerator<RelationshipsArrayGenerator>>(MockBehavior.Strict);
+        var externalDocumentReferenceGeneratorMock = new Mock<IJsonArrayGenerator<ExternalDocumentReferenceGenerator>>(MockBehavior.Strict);
+
+        var sbomConfigsMock = new Mock<ISbomConfigProvider>(MockBehavior.Strict);
+        sbomConfigsMock.Setup(f => f.TryGet(It.IsAny<ManifestInfo>(), out sbomConfig)).Returns(false);
+
+        var workflow = new SbomGenerationWorkflow(
+            configurationMock.Object,
+            fileSystemMock.Object,
+            mockLogger.Object,
+            fileArrayGeneratorMock.Object,
+            packageArrayGeneratorMock.Object,
+            relationshipsArrayGeneratorMock.Object,
+            externalDocumentReferenceGeneratorMock.Object,
+            sbomConfigsMock.Object,
+            mockOSUtils.Object,
+            recorderMock.Object);
+
+        var result = await workflow.RunAsync();
+
+        fileArrayGeneratorMock.VerifyAll();
+        packageArrayGeneratorMock.VerifyAll();
+        relationshipsArrayGeneratorMock.VerifyAll();
+        externalDocumentReferenceGeneratorMock.VerifyAll();
+        sbomConfigsMock.VerifyAll();
+        fileSystemMock.Verify(f => f.DeleteDir(It.IsAny<string>(), true), Times.Once);
         Assert.IsFalse(result);
+
+        Assert.AreEqual(1, warnings.Count);
+        Assert.IsTrue(warnings[0].StartsWith("Ignoring unregistered manifest type: ", StringComparison.Ordinal), $"Unexpected warning: {warnings[0]}");
     }
 }


Reviews:
user: login: DaveTryon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T16:19:36Z
user: login: pragnya17
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T17:53:34Z
user: login: pragnya17
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T18:16:32Z
user: login: DaveTryon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T19:12:04Z
user: login: pragnya17
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T19:28:06Z

Review comments:
diff_hunk: @@ -453,7 +454,7 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
         using var manifestStream = new MemoryStream();
 
         fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny<string>())).Returns(true);
-        var sbomConfig = new SbomConfig(fileSystemMock.Object)
+        ISbomConfig sbomConfig = new SbomConfig(fileSystemMock.Object)
path: test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
user: login: DaveTryon
body: Changed `var` to `ISbomConfig` because `Mock.Setup` with an `out` parameter is very finicky about exactly matching the type
created_at: 2025-05-05T16:19:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -482,7 +483,51 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
         externalDocumentReferenceGeneratorMock.Setup(f => f.GenerateAsync()).ReturnsAsync(generationResult);
 
         var sbomConfigsMock = new Mock<ISbomConfigProvider>();
-        sbomConfigsMock.Setup(f => f.Get(It.IsAny<ManifestInfo>())).Returns(sbomConfig);
+        sbomConfigsMock.Setup(f => f.TryGet(It.IsAny<ManifestInfo>(), out sbomConfig)).Returns(true);
+
+        var workflow = new SbomGenerationWorkflow(
+            configurationMock.Object,
+            fileSystemMock.Object,
+            mockLogger.Object,
+            fileArrayGeneratorMock.Object,
+            packageArrayGeneratorMock.Object,
+            relationshipsArrayGeneratorMock.Object,
+            externalDocumentReferenceGeneratorMock.Object,
+            sbomConfigsMock.Object,
+            mockOSUtils.Object,
+            recorderMock.Object);
+
+        var result = await workflow.RunAsync();
+
+        fileArrayGeneratorMock.VerifyAll();
+        packageArrayGeneratorMock.VerifyAll();
+        relationshipsArrayGeneratorMock.VerifyAll();
+        externalDocumentReferenceGeneratorMock.VerifyAll();
+        fileSystemMock.Verify(f => f.DeleteDir(It.IsAny<string>(), true), Times.Once);
path: test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
user: login: pragnya17
body: Should we remove `Assert.IsFalse(result);` ? It is present in the previous code, not sure of the rationale behind removing that assert statement
created_at: 2025-05-05T17:52:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -482,7 +483,51 @@ public async Task ManifestGenerationWorkflowTests_SBOMDir_NotDefault_NotDeleted(
         externalDocumentReferenceGeneratorMock.Setup(f => f.GenerateAsync()).ReturnsAsync(generationResult);
 
         var sbomConfigsMock = new Mock<ISbomConfigProvider>();
-        sbomConfigsMock.Setup(f => f.Get(It.IsAny<ManifestInfo>())).Returns(sbomConfig);
+        sbomConfigsMock.Setup(f => f.TryGet(It.IsAny<ManifestInfo>(), out sbomConfig)).Returns(true);
+
+        var workflow = new SbomGenerationWorkflow(
+            configurationMock.Object,
+            fileSystemMock.Object,
+            mockLogger.Object,
+            fileArrayGeneratorMock.Object,
+            packageArrayGeneratorMock.Object,
+            relationshipsArrayGeneratorMock.Object,
+            externalDocumentReferenceGeneratorMock.Object,
+            sbomConfigsMock.Object,
+            mockOSUtils.Object,
+            recorderMock.Object);
+
+        var result = await workflow.RunAsync();
+
+        fileArrayGeneratorMock.VerifyAll();
+        packageArrayGeneratorMock.VerifyAll();
+        relationshipsArrayGeneratorMock.VerifyAll();
+        externalDocumentReferenceGeneratorMock.VerifyAll();
+        fileSystemMock.Verify(f => f.DeleteDir(It.IsAny<string>(), true), Times.Once);
path: test/Microsoft.Sbom.Api.Tests/Workflows/ManifestGenerationWorkflowTests.cs
user: login: DaveTryon
body: Added 😃 
created_at: 2025-05-05T19:12:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: DaveTryon
created_at: 2025-05-03T00:38:41Z
body: /azp run
author_association: CONTRIBUTOR
user: login: DaveTryon
created_at: 2025-05-05T18:16:30Z
body: /azp run
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-05T19:10:54Z
body: This PR changes files in the API project. Does it change _any_ of the API interfaces in _any way_? Please note that this includes the following types of changes:
- Changing the signature of an existing interface method
- Adding a new method to an existing interface
- Adding a required data member to a class that an existing interface method consumes

Because any of these changes can potentially break a downstream consumer with customized interface implementations, these changes need to be treated as breaking changes. Please do one of the following:

## Option 1 - Publish this as a breaking change
1. Update the documentation to show the new functionality
2. Bump the major version in the next release
3. Be sure to highlight the breaking changes in the release notes

## Option 2 - Refactor the changes to be non-breaking
1. Review [this commit](https://github.com/microsoft/sbom-tool/commit/4d0ce83e194ed6feace53666aeb6280f5b8b8769), which adds a new interface in a backward-compatible way
2. Refactor the change to follow this pattern so that existing interfaces are left completely intact
3. Bump the minor version in the next release

author_association: NONE
user: login: DaveTryon
created_at: 2025-05-05T19:12:28Z
body: /azp run
author_association: CONTRIBUTOR