diff --git a/src/CycloneDX.Utils/ComponentVersionDiff.cs b/src/CycloneDX.Utils/ComponentVersionDiff.cs index 93671534..dea3b810 100644 --- a/src/CycloneDX.Utils/ComponentVersionDiff.cs +++ b/src/CycloneDX.Utils/ComponentVersionDiff.cs @@ -46,15 +46,17 @@ public static Dictionary> ComponentVersionDiff(Bom f var result = new Dictionary>(); // make a copy of components that are still to be processed - var fromComponents = new List(fromBom.Components); - var toComponents = new List(toBom.Components); + var fromBomComponents = EnumerateComponentTree(fromBom.Components).ToList(); + var toBomComponents = EnumerateComponentTree(toBom.Components).ToList(); + var fromComponents = new List(fromBomComponents); + var toComponents = new List(toBomComponents); // unchanged component versions - // loop over the toBom and fromBom Components list as we will be modifying the fromComponents list - foreach (var fromComponent in fromBom.Components) + // loop over the complete lists as we will be modifying the remaining components + foreach (var fromComponent in fromBomComponents) { // if component version is in both SBOMs - if (toBom.Components.Count(toComponent => + if (toBomComponents.Count(toComponent => toComponent.Group == fromComponent.Group && toComponent.Name == fromComponent.Name && toComponent.Version == fromComponent.Version @@ -100,5 +102,22 @@ public static Dictionary> ComponentVersionDiff(Bom f return result; } + + private static IEnumerable EnumerateComponentTree(IEnumerable components) + { + var toVisit = new Stack(components.Reverse()); + while (toVisit.Count > 0) + { + var component = toVisit.Pop(); + yield return component; + if (component.Components != null) + { + foreach (var child in component.Components.Reverse()) + { + toVisit.Push(child); + } + } + } + } } } diff --git a/tests/CycloneDX.Utils.Tests/NestedComponentVersionDiffTests.cs b/tests/CycloneDX.Utils.Tests/NestedComponentVersionDiffTests.cs new file mode 100644 index 00000000..48728ef8 --- /dev/null +++ b/tests/CycloneDX.Utils.Tests/NestedComponentVersionDiffTests.cs @@ -0,0 +1,123 @@ +// This file is part of CycloneDX Library for .NET +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +using System.Collections.Generic; +using CycloneDX.Models; +using Xunit; + +namespace CycloneDX.Utils.Tests +{ + public class NestedComponentVersionDiffTests + { + [Theory] + [InlineData(0, "1", "2")] + [InlineData(0, null, "2")] + [InlineData(0, "1", null)] + [InlineData(0, "1", "1")] + [InlineData(1, "1", "2")] + [InlineData(1, null, "2")] + [InlineData(1, "1", null)] + [InlineData(1, "1", "1")] + [InlineData(2, "1", "2")] + [InlineData(2, null, "2")] + [InlineData(2, "1", null)] + [InlineData(2, "1", "1")] + public void ComponentVersionsAtEachDepthAreCompared(int depth, string fromVersion, string toVersion) + { + var fromBom = CreateBom(depth, fromVersion); + var toBom = CreateBom(depth, toVersion); + + var result = CycloneDXUtils.ComponentVersionDiff(fromBom, toBom); + + Assert.True(result.ContainsKey("library")); + var diff = result["library"]; + Assert.Equal(fromVersion != null && fromVersion != toVersion ? 1 : 0, diff.Removed.Count); + Assert.Equal(toVersion != null && fromVersion != toVersion ? 1 : 0, diff.Added.Count); + Assert.Equal(fromVersion == toVersion ? 1 : 0, diff.Unchanged.Count); + if (diff.Removed.Count > 0) { Assert.Equal(fromVersion, diff.Removed[0].Version); } + if (diff.Added.Count > 0) { Assert.Equal(toVersion, diff.Added[0].Version); } + for (var i = 0; i < depth; i++) + { + Assert.Single(result[$"assembly-{i}"].Unchanged); + Assert.Empty(result[$"assembly-{i}"].Added); + Assert.Empty(result[$"assembly-{i}"].Removed); + } + } + + [Fact] + public void MovingAComponentIntoAnAssemblyKeepsItsVersionUnchanged() + { + var result = CycloneDXUtils.ComponentVersionDiff(CreateBom(0, "1"), CreateBom(2, "1")); + + Assert.True(result.ContainsKey("library")); + Assert.Single(result["library"].Unchanged); + Assert.Empty(result["library"].Added); + Assert.Empty(result["library"].Removed); + Assert.Single(result["assembly-0"].Added); + Assert.Single(result["assembly-1"].Added); + } + + [Fact] + public void PedigreeComponentsAreNotIncludedInTheVersionDiff() + { + var fromBom = CreateBom(0, "1"); + var toBom = CreateBom(0, "1"); + toBom.Components[0].Pedigree = new Pedigree + { + Ancestors = new List + { + new Component { Type = Component.Classification.Library, Name = "ancestor", Version = "0" } + } + }; + + var result = CycloneDXUtils.ComponentVersionDiff(fromBom, toBom); + + Assert.Single(result); + Assert.Single(result["library"].Unchanged); + } + + private static Bom CreateBom(int depth, string version) + { + var components = new List(); + if (version != null) + { + components.Add(new Component + { + Type = Component.Classification.Library, + Name = "library", + Version = version + }); + } + + for (var i = 0; i < depth; i++) + { + components = new List + { + new Component + { + Type = Component.Classification.Application, + Name = $"assembly-{i}", + Version = "1", + Components = components + } + }; + } + + return new Bom { Components = components }; + } + } +}