diff --git a/src/CycloneDX.Core/BomUtils.cs b/src/CycloneDX.Core/BomUtils.cs index 27c14fce..89e92e38 100644 --- a/src/CycloneDX.Core/BomUtils.cs +++ b/src/CycloneDX.Core/BomUtils.cs @@ -455,4 +455,4 @@ public static void EnumerateAllToolChoices(Bom bom, Action callback }); } } -} \ No newline at end of file +} diff --git a/src/CycloneDX.Core/Json/Serializer.Serialization.cs b/src/CycloneDX.Core/Json/Serializer.Serialization.cs index 921d96ca..cd862512 100644 --- a/src/CycloneDX.Core/Json/Serializer.Serialization.cs +++ b/src/CycloneDX.Core/Json/Serializer.Serialization.cs @@ -33,6 +33,14 @@ namespace CycloneDX.Json public static partial class Serializer { private static JsonSerializerOptions _options = Utils.GetJsonSerializerOptions(); + private static readonly JsonSerializerOptions _options_compact = new Func(() => + { + JsonSerializerOptions opts = Utils.GetJsonSerializerOptions(); + opts.AllowTrailingCommas = false; + opts.WriteIndented = false; + + return opts; + }) (); /// /// Serializes a CycloneDX BOM writing the output to a stream. @@ -58,36 +66,60 @@ public static string Serialize(Bom bom) return jsonBom; } - internal static string Serialize(Component component) - { - Contract.Requires(component != null); - return JsonSerializer.Serialize(component, _options); - } - - internal static string Serialize(Dependency dependency) - { - Contract.Requires(dependency != null); - return JsonSerializer.Serialize(dependency, _options); - } - - internal static string Serialize(Service service) + /// + /// Return serialization of a class derived from BomEntity + /// with common JsonSerializerOptions defined for this class. + /// + /// A BomEntity-derived class + /// String with JSON markup + internal static string Serialize(BomEntity entity) { - Contract.Requires(service != null); - return JsonSerializer.Serialize(service, _options); + return Serialize(entity, _options); } - #pragma warning disable 618 - internal static string Serialize(Tool tool) + /// + /// Return serialization of a class derived from BomEntity + /// with compact JsonSerializerOptions aimed at minimal + /// markup (harder to read for humans, less bytes to parse). + /// + /// A BomEntity-derived class + /// String with JSON markup + internal static string SerializeCompact(BomEntity entity) { - Contract.Requires(tool != null); - return JsonSerializer.Serialize(tool, _options); + return Serialize(entity, _options_compact); } - #pragma warning restore 618 - internal static string Serialize(Models.Vulnerabilities.Vulnerability vulnerability) + /// + /// Return serialization of a class derived from BomEntity + /// with caller-specified JsonSerializerOptions. + /// + /// A BomEntity-derived class + /// Options for serializer + /// String with JSON markup + internal static string Serialize(BomEntity entity, JsonSerializerOptions jserOptions) { - Contract.Requires(vulnerability != null); - return JsonSerializer.Serialize(vulnerability, _options); + Contract.Requires(entity != null); + // Default code tends to return serialization of base class + // => empty (no props in BomEntity itself) so we have to + // coerce it into seeing the object type we need to parse. + // This codepath is critical for us since serialization is + // used to compare if entities are Equal() in massive loops + // when merging Bom's. Optimizations welcome. + string res = null; + if (BomEntity.KnownEntityTypeLists.TryGetValue(entity.GetType(), out var listInfo) + && listInfo != null && listInfo.genericType != null + && listInfo.methodAdd != null && listInfo.methodGetItem != null + ) { + var castList = Activator.CreateInstance(listInfo.genericType); + listInfo.methodAdd.Invoke(castList, new object[] { entity }); + res = JsonSerializer.Serialize(listInfo.methodGetItem.Invoke(castList, new object[] { 0 }), jserOptions); + } + else + { + var castEntity = Convert.ChangeType(entity, entity.GetType()); + res = JsonSerializer.Serialize(castEntity, jserOptions); + } + return res; } } } diff --git a/src/CycloneDX.Core/Json/Validator.cs b/src/CycloneDX.Core/Json/Validator.cs index 205c5290..2a606f6e 100644 --- a/src/CycloneDX.Core/Json/Validator.cs +++ b/src/CycloneDX.Core/Json/Validator.cs @@ -170,6 +170,100 @@ public static ValidationResult Validate(string jsonString, SpecificationVersion } } + /// + /// Merge two dictionaries whose values are lists of JsonElements, + /// adding all entries from list in dict2 for the same key as in + /// dict1 (or adds a new entry for a new key). Manipulates a COPY + /// of dict1, then returns this copy. + /// + /// Dict with lists as values + /// Dict with lists as values + /// Copy of dict1+dict2 + private static Dictionary> addDictList( + Dictionary> dict1, + Dictionary> dict2) + { + if (dict2 == null || dict2.Count == 0) + { + return dict1; + } + + if (dict1 == null || dict1.Count == 0) + { + return dict2; + } + + foreach (KeyValuePair> KVP in dict2) + { + if (dict1.ContainsKey(KVP.Key)) + { + // NOTE: Possibly different object, but same string representation! + dict1[KVP.Key].AddRange(KVP.Value); + } + else + { + dict1.Add(KVP.Key, KVP.Value); + } + } + + return dict1; + } + + /// + /// Iterate through the JSON document to find JSON objects whose property names + /// match the one we seek, and add such hits to returned list. Recurse and repeat. + /// + /// A JsonElement, starting from JsonDocument.RootElement + /// for the original caller, probably. Then used to recurse. + /// + /// The property name we seek. + /// A Dictionary with distinct values of string representation of the + /// seeked JsonElement as keys, and a List of actual JsonElement objects as + /// mapped values. + /// + private static Dictionary> findNamedElements(JsonElement element, string name) + { + Dictionary> hits = new Dictionary>(); + Dictionary> nestedHits = null; + + // Can we iterate further? + switch (element.ValueKind) { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name == name) { + string key = property.Value.ToString(); + if (!(hits.ContainsKey(key))) + { + hits.Add(key, new List()); + } + hits[key].Add(property.Value); + } + + // Note: Here we can recurse into same property that + // we've just listed, if it is not of a simple kind. + nestedHits = findNamedElements(property.Value, name); + hits = addDictList(hits, nestedHits); + } + break; + + case JsonValueKind.Array: + foreach (JsonElement nestedElem in element.EnumerateArray()) + { + nestedHits = findNamedElements(nestedElem, name); + hits = addDictList(hits, nestedHits); + } + break; + + default: + // No-op for simple types: these values per se have no name + // to learn, and we can not iterate deeper into them. + break; + } + + return hits; + } + private static ValidationResult Validate(JsonSchema schema, JsonDocument jsonDocument, string schemaVersionString) { var validationMessages = new List(); @@ -194,6 +288,33 @@ private static ValidationResult Validate(JsonSchema schema, JsonDocument jsonDoc } } } + + // The JSON Schema, at least the ones defined by CycloneDX + // and handled by current parser in dotnet ecosystem, can + // not specify or check the uniqueness requirement for the + // "bom-ref" assignments in the overall document (e.g. in + // "metadata/component" and list of "components", as well + // as in "services" and "vulnerabilities", as of CycloneDX + // spec v1.4), so this is checked separately here if the + // document seems structurally intact otherwise. + // Note that this is not a problem for the XML schema with + // its explicit constraint. + Dictionary> bomRefs = findNamedElements(jsonDocument.RootElement, "bom-ref"); + foreach (KeyValuePair> KVP in bomRefs) { + if (KVP.Value != null && KVP.Value.Count != 1) { + validationMessages.Add($"'bom-ref' value of {KVP.Key}: expected 1 mention, actual {KVP.Value.Count}"); + } + } + + // Check that if we "ref" something (from dependencies, etc.) + // the corresponding "bom-ref" exists in this document: + List bomRefsList = new List(bomRefs.Keys); + Dictionary> useRefs = findNamedElements(jsonDocument.RootElement, "ref"); + foreach (KeyValuePair> KVP in useRefs) { + if (KVP.Value != null && KVP.Value.Count > 0 && !(bomRefsList.Contains(KVP.Key))) { + validationMessages.Add($"'ref' value of {KVP.Key} was used in {KVP.Value.Count} place(s); expected a 'bom-ref' defined for it, but there was none"); + } + } } else { diff --git a/src/CycloneDX.Core/ListMergeHelper.cs b/src/CycloneDX.Core/ListMergeHelper.cs new file mode 100644 index 00000000..28920639 --- /dev/null +++ b/src/CycloneDX.Core/ListMergeHelper.cs @@ -0,0 +1,254 @@ +// 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; +using System.Collections.Generic; +using System.Reflection; +using System.Text.RegularExpressions; +using CycloneDX.Models; + +namespace CycloneDX +{ + /// + /// Allows to merge generic lists with items of specified types + /// (by default essentially adding entries which are not present + /// yet according to List.Contains() method), and calls special + /// logic for lists of BomEntry types.
+ /// + /// Used in CycloneDX.Utils various Merge implementations as well + /// as in CycloneDX.Core BomEntity-derived classes' MergeWith().
+ /// + /// Does not modify original lists and returns a new instance + /// with merged data. One exception is if one of the inputs is + /// null or empty - then the other object is returned. + ///
+ /// Type of listed entries + public class ListMergeHelper + { + public List Merge(List list1, List list2) + { + return Merge(list1, list2, BomEntityListMergeHelperStrategy.Default()); + } + + public List Merge(List list1, List list2, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + if (!int.TryParse(System.Environment.GetEnvironmentVariable("CYCLONEDX_DEBUG_MERGE"), out int iDebugLevel) || iDebugLevel < 0) + { + iDebugLevel = 0; + } + + // Rule out utterly empty inputs + if ((list1 is null || list1.Count < 1) && (list2 is null || list2.Count < 1)) + { + if (!(list1 is null)) + { + return list1; + } + if (!(list2 is null)) + { + return list2; + } + return new List(); + } + + // At least one of these entries exists, per above sanity check + if (typeof(BomEntity).IsInstanceOfType((!(list1 is null) && list1.Count > 0) ? list1[0] : list2[0])) + { + MethodInfo methodMerge = null; + Object helper; + // Use cached info where available + if (BomEntity.KnownBomEntityListMergeHelpers.TryGetValue(typeof(T), out BomEntityListMergeHelperReflection refInfo)) + { + methodMerge = refInfo.methodMerge; + helper = refInfo.helperInstance; + } + else + { + // Inspired by https://stackoverflow.com/a/4661237/4715872 + // to craft a List "result" at run-time: + Type listHelperType = typeof(BomEntityListMergeHelper<>); + var constructedListHelperType = listHelperType.MakeGenericType(typeof(T)); + helper = Activator.CreateInstance(constructedListHelperType); + // Gotta use reflection for run-time evaluated type methods: + methodMerge = constructedListHelperType.GetMethod("Merge", 0, new [] { typeof(List), typeof(List), typeof(BomEntityListMergeHelperStrategy) }); + } + + if (methodMerge != null) + { + return (List)methodMerge.Invoke(helper, new object[] {list1, list2, listMergeHelperStrategy}); + } + else + { + // Should not get here, but if we do - log and fall through + if (iDebugLevel >= 1) + { + Console.WriteLine($"Warning: List-Merge for BomEntity failed to find a Merge() helper method: {list1?.GetType()?.ToString()} and {list2?.GetType()?.ToString()}"); + } + } + } + + // Lists of legacy types (for BomEntity we use BomEntityListMergeHelper class) + if (iDebugLevel >= 1) + { + Console.WriteLine($"List-Merge for legacy types: {list1?.GetType()?.ToString()} and {list2?.GetType()?.ToString()}"); + } + + if (list1 is null || list1.Count < 1) + { + return list2; + } + if (list2 is null || list2.Count < 1) + { + return list1; + } + + var result = new List(list1); + + foreach (var item in list2) + { + if (!(result.Contains(item))) + { + result.Add(item); + } + } + + return result; + } + + // Adapted from https://stackoverflow.com/a/76523292/4715872 + public void SortByAscending(List list) + { + SortByImpl(true, false, list, null, null); + } + + public void SortByAscending(List list, bool recursive) + { + SortByImpl(true, recursive, list, null, null); + } + + public void SortByAscending(List list, Func selector) + { + SortByImpl(true, false, list, selector, null); + } + + public void SortByAscending(List list, Func selector, IComparer comparer) + { + SortByImpl(true, false, list, selector, comparer); + } + + public void SortByDescending(List list) + { + SortByImpl(false, false, list, null, null); + } + + public void SortByDescending(List list, bool recursive) + { + SortByImpl(false, recursive, list, null, null); + } + + public void SortByDescending(List list, Func selector) + { + SortByImpl(false, false, list, selector, null); + } + + public void SortByDescending(List list, Func selector, IComparer comparer) + { + SortByImpl(false, false, list, selector, comparer); + } + + /// + /// Implementation of the sort algorithm. + /// Special handling for BomEntity-derived objects, including + /// optional recursion to have them sort their list-of-something + /// properties. + /// + /// ValueTuple of function parameters returned by selector lambda + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + /// lambda to select a tuple of properties to sort by + /// null for default, or a custom comparer + public void SortByImpl(bool ascending, bool recursive, List list, Func selector, IComparer comparer) + { + if (list is null || list.Count < 2) + { + // No-op quickly for null, empty or single-item lists + return; + } + + // Ordering proposed in those NormalizeList() implementations + // is an educated guess. Main purpose for this is to have + // consistently ordered serialized BomEntity-derived type + // lists for the purposes of comparison and compression. + if (selector is null && typeof(BomEntity).IsInstanceOfType(list[0])) + { + // This should really be offloaded as lambdas into the + // BomEntity-derived classes themselves, but I've struggled + // to cast the right magic spells at C# to please its gods. + // In particular, the ValueTuple used in selector signature is + // both generic for the values' types (e.g. ), + // and for their amount in the tuple (0, 1, 2, ... explicitly + // stated). So this is the next best thing... + + // Alas, C# won't let us just call + // BomEntity.NormalizeList(ascending, recursive, (List)list) or + // something as simple, so here it goes - some more reflection: + var methodNormalizeList = typeof(BomEntity).GetMethod("NormalizeList", + BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly, + new [] { typeof(bool), typeof(bool), typeof(List) }); + + if (methodNormalizeList != null) + { + if (BomEntity.KnownEntityTypeLists.TryGetValue(typeof(BomEntity), out BomEntityListReflection refInfoListInterface)) + { + if (BomEntity.KnownEntityTypeLists.TryGetValue(list[0].GetType(), out BomEntityListReflection refInfoListType)) + { + // Gotta make ugly cast copies there and back: + List helper = (List)Activator.CreateInstance(refInfoListInterface.genericType); + refInfoListInterface.methodAddRange.Invoke(helper, new object[] {list}); + + methodNormalizeList.Invoke(null, new object[] {ascending, recursive, helper}); + + // Populate back the original list object: + list.Clear(); + foreach (var item in helper) + { + refInfoListType.methodAdd.Invoke(list, new object[] {item}); + } + } + } + } // else keep it as was? no good cause for an exception?.. + + return; + } + + if (comparer is null) + { + comparer = Comparer.Default; + } + + if (ascending) + { + list.Sort((a, b) => comparer.Compare(selector(a), selector(b))); + } + else + { + list.Sort((a, b) => comparer.Compare(selector(b), selector(a))); + } + } + } +} diff --git a/src/CycloneDX.Core/Models/Annotation.cs b/src/CycloneDX.Core/Models/Annotation.cs index bf10b666..2f623421 100644 --- a/src/CycloneDX.Core/Models/Annotation.cs +++ b/src/CycloneDX.Core/Models/Annotation.cs @@ -15,8 +15,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; using System.Xml.Serialization; using System.Text.Json.Serialization; using ProtoBuf; @@ -24,13 +27,31 @@ namespace CycloneDX.Models { [ProtoContract] - public class Annotation + public class Annotation : BomEntity, IBomEntityWithRefType_String_BomRef + // NOTE: *Not* IBomEntityWithRefLinkType_StringList due + // to inlaid "subject" property type with dedicated class { [XmlType("subject")] - public class XmlAnnotationSubject + public class XmlAnnotationSubject : BomEntity, IBomEntityWithRefLinkType_String_Ref { [XmlAttribute("ref")] public string Ref { get; set; } + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_AnyBomEntity = + new Dictionary> + { + { typeof(XmlAnnotationSubject).GetProperty("Ref", typeof(string)), RefLinkConstraints_AnyBomEntity } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + // TODO: switch/case for CDX spec newer than 1.5 where this type got introduced + if (specificationVersion == v1_5) + { + return RefLinkConstraints_StringRef_AnyBomEntity; + } + return null; + } } [XmlAttribute("bom-ref")] diff --git a/src/CycloneDX.Core/Models/AnnotatorChoice.cs b/src/CycloneDX.Core/Models/AnnotatorChoice.cs index b996fec3..5e5051cb 100644 --- a/src/CycloneDX.Core/Models/AnnotatorChoice.cs +++ b/src/CycloneDX.Core/Models/AnnotatorChoice.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class AnnotatorChoice + public class AnnotatorChoice : BomEntity { [XmlElement("organization")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/AttachedText.cs b/src/CycloneDX.Core/Models/AttachedText.cs index 26b8d004..fbf1bbd8 100644 --- a/src/CycloneDX.Core/Models/AttachedText.cs +++ b/src/CycloneDX.Core/Models/AttachedText.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class AttachedText + public class AttachedText : BomEntity { [XmlAttribute("content-type")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Bom.cs b/src/CycloneDX.Core/Models/Bom.cs index ed3c6644..3a2e17a0 100644 --- a/src/CycloneDX.Core/Models/Bom.cs +++ b/src/CycloneDX.Core/Models/Bom.cs @@ -17,8 +17,10 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Reflection; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -29,7 +31,7 @@ namespace CycloneDX.Models [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] [XmlRoot("bom", IsNullable=false)] [ProtoContract] - public class Bom + public class Bom : BomEntity { [XmlIgnore] public string BomFormat => "CycloneDX"; @@ -168,5 +170,754 @@ public int NonNullableVersion [ProtoMember(13)] public List Formulation { get; set; } public bool ShouldSerializeFormulation() { return Formulation?.Count > 0; } + + // TODO: MergeWith() might be reasonable but is currently handled + // by several strategy implementations in CycloneDX.Utils Merge.cs + // so maybe there should be sub-classes or strategy arguments or + // properties to select one of those implementations at run-time?.. + + /// + /// Add reference to this currently running build of cyclonedx-cli + /// (likely) and this cyclonedx-dotnet-library into the Metadata/Tools + /// of this Bom document. Intended for use after processing which + /// creates or modifies the document. After all - any bugs appearing + /// due to library routines are our own and should be trackable... + /// + /// NOTE: Tries to not add identical duplicate entries. + /// + public void BomMetadataReferThisToolkit() + { + // Per https://stackoverflow.com/a/36351902/4715872 : + // Use System.Reflection.Assembly.GetExecutingAssembly() + // to get the assembly (that this line of code is in), or + // use System.Reflection.Assembly.GetEntryAssembly() to + // get the assembly your project started with (most likely + // this is your app). In multi-project solutions this is + // something to keep in mind! + #pragma warning disable 618 + Tool toolThisLibrary = new Tool + { + Vendor = "OWASP Foundation", + Name = Assembly.GetExecutingAssembly().GetName().Name, // "cyclonedx-dotnet-library" + Version = Assembly.GetExecutingAssembly().GetName().Version.ToString() + }; + #pragma warning restore 618 + + if (this.Metadata is null) + { + this.Metadata = new Metadata(); + } + + if (this.Metadata.Tools is null || this.Metadata.Tools.Tools is null) + { + #pragma warning disable 618 + this.Metadata.Tools = new ToolChoices + { + Tools = new List(new [] {toolThisLibrary}), + }; + #pragma warning restore 618 + } + else + { + if (!this.Metadata.Tools.Tools.Contains(toolThisLibrary)) + { + this.Metadata.Tools.Tools.Add(toolThisLibrary); + } + } + + // At worst, these would dedup away?.. + string toolThisScriptName = Assembly.GetEntryAssembly().GetName().Name; // "cyclonedx-cli" or similar + if (toolThisScriptName != toolThisLibrary.Name) + { + #pragma warning disable 618 + Tool toolThisScript = new Tool + { + Name = toolThisScriptName, + Vendor = (toolThisScriptName.ToLowerInvariant().StartsWith("cyclonedx") ? "OWASP Foundation" : null), + Version = Assembly.GetEntryAssembly().GetName().Version.ToString() + }; + #pragma warning restore 618 + + if (!this.Metadata.Tools.Tools.Contains(toolThisScript)) + { + this.Metadata.Tools.Tools.Add(toolThisScript); + } + } + } + + /// + /// Update the Metadata/Timestamp of this Bom document + /// (after content manipulations such as a merge) + /// using DateTime.Now. + /// + /// NOTE: Creates a new Metadata object to populate + /// the property, if one was missing in this Bom object. + /// + public void BomMetadataUpdateTimestamp() + { + if (this.Metadata is null) + { + this.Metadata = new Metadata(); + } + + this.Metadata.Timestamp = DateTime.Now; + } + + /// + /// Update the SerialNumber and optionally bump the Version + /// of a Bom document issued with such serial number (both + /// not in the Metadata structure, but still are "meta data") + /// of this Bom document, either using a new random UUID as + /// the SerialNumber and assigning a Version=1, or bumping + /// the Version -- usually done after content manipulations + /// such as a merge, depending on their caller-defined impact. + /// + public void BomMetadataUpdateSerialNumberVersion(bool generateNewSerialNumber) + { + bool doGenerateNewSerialNumber = generateNewSerialNumber; + if (this.Version is null || this.Version < 1 || this.SerialNumber is null || this.SerialNumber == "") + { + doGenerateNewSerialNumber = true; + } + + if (doGenerateNewSerialNumber) + { + this.Version = 1; + this.SerialNumber = "urn:uuid:" + System.Guid.NewGuid().ToString(); + } + else + { + this.Version++; + } + } + + /// + /// Set up (default or update) meta data of this Bom document, + /// covering the Version, SerialNumber and Metadata/Timestamp + /// in one shot. Typically useful to brush up a `new Bom()` or + /// to ensure a new identity for a modified Bom document. + /// + /// NOTE: caller may want to BomMetadataReferThisToolkit() + /// separately, to add the Metadata/Tools[] entries about this + /// CycloneDX library and its consumer (e.g. the "cyclonedx-cli" + /// program). + /// + public void BomMetadataUpdate(bool generateNewSerialNumber) + { + this.BomMetadataUpdateSerialNumberVersion(generateNewSerialNumber); + this.BomMetadataUpdateTimestamp(); + } + + /// + /// Prepare a BomWalkResult discovery report starting from + /// this Bom document. Callers can cache it to re-use for + /// repetitive operations. + /// + /// + public BomWalkResult WalkThis() + { + BomWalkResult res = new BomWalkResult(); + res.reset(this); + + // Note: passing "container=null" should be safe here, as + // long as this Bom type does not have a BomRef property. + res.SerializeBomEntity_BomRefs(this, null); + + return res; + } + + /// + /// Helper for sanity-check of inputs for methods that deal + /// with BomWalkResult arguments that should refer to "this" + /// exact Bom document instance as their bomRoot. + /// + /// Result of an earlier Bom.WalkThis() or equivalent call + /// The "res" argument should be non-null + /// The "res" argument should point to this Bom instance + private void AssertThisBomWalkResult(BomWalkResult res) + { + if (res == null) + { + throw new ArgumentNullException("res"); + } + + if (!(Object.ReferenceEquals(res.bomRoot, this))) + { + throw new BomEntityConflictException( + "The specified BomWalkResult.bomRoot does not refer to this Bom document instance", + res.bomRoot.GetType()); + } + } + + /// + /// Provide a Dictionary whose keys are "Ref" or equivalent + /// string values which link back to a "BomRef" hopefully + /// defined somewhere in the same Bom document (but may be + /// dangling, or sometimes co-opted with external links to + /// other Bom documents!), and whose values are lists of + /// BomEntities which use this same "ref" value. + /// + /// See also: GetBomRefsInContainers() with similar info + /// about keys which are BomEntity "containers" and values + /// are lists of BomEntity with a BomRef in those containers, + /// and GetBomRefsWithContainer() with transposed returns. + /// + /// + public Dictionary> GetRefsInContainers(BomWalkResult res) + { + AssertThisBomWalkResult(res); + return res.GetRefsInContainers(); + } + + /// + /// This is a run-once method to get a dictionary. + /// See GetRefsInContainers(BomWalkResult) for one using a cache + /// prepared by WalkThis() for mass manipulations. + /// + /// + public Dictionary> GetRefsInContainers() + { + BomWalkResult res = WalkThis(); + return GetRefsInContainers(res); + } + + /// + /// Provide a Dictionary whose keys are container BomEntities + /// and values are lists of one or more directly contained + /// entities with a BomRef attribute, e.g. the Bom itself and + /// the Components in it; or the Metadata and the Component + /// description in it; or certain Components or Tools with a + /// set of further "structural" components. + /// + /// The assumption per CycloneDX spec, not directly challenged + /// in this method, is that each such listed "contained entity" + /// (likely Component instances) has an unique BomRef value across + /// the whole single Bom document. Other Bom documents may however + /// have the same BomRef value (trivially "1", "2", ...) which + /// is attached to description of an unrelated entity. This can + /// impact such operations as a FlatMerge() of different Boms. + /// + /// See also: GetBomRefsWithContainer() with transposed returns. + /// + /// + public Dictionary> GetBomRefsInContainers(BomWalkResult res) + { + AssertThisBomWalkResult(res); + return res.GetBomRefsInContainers(); + } + + /// + /// This is a run-once method to get a dictionary. + /// See GetBomRefsInContainers(BomWalkResult) for one using a cache + /// prepared by WalkThis() for mass manipulations. + /// + /// + public Dictionary> GetBomRefsInContainers() + { + BomWalkResult res = WalkThis(); + return GetBomRefsInContainers(res); + } + + /// + /// Provide a Dictionary whose keys are "contained" entities + /// with a BomRef attribute and values are their direct + /// container BomEntities, e.g. each Bom.Components[] list + /// entry referring the Bom itself; or the Metadata.Component + /// entry referring the Metadata; or further "structural" + /// components in certain Component or Tool entities. + /// + /// The assumption per CycloneDX spec, not directly challenged + /// in this method, is that each such listed "contained entity" + /// (likely Component instances) has an unique BomRef value across + /// the whole single Bom document. Other Bom documents may however + /// have the same BomRef value (trivially "1", "2", ...) which + /// is attached to description of an unrelated entity. This can + /// impact such operations as a FlatMerge() of different Boms. + /// + /// See also: GetBomRefsInContainers() with transposed returns. + /// + /// + public Dictionary GetBomRefsWithContainer(BomWalkResult res) + { + AssertThisBomWalkResult(res); + return res.GetBomRefsWithContainer(); + } + + /// + /// This is a run-once method to get a dictionary. + /// See GetBomRefsWithContainer(BomWalkResult) for one using a cache + /// prepared by WalkThis() for mass manipulations. + /// + /// + public Dictionary GetBomRefsWithContainer() + { + BomWalkResult res = WalkThis(); + return res.GetBomRefsWithContainer(); + } + + /// + /// Rename all occurrences of the "BomRef" (its value definition + /// to name an entity, if present in this Bom document, and the + /// references to it from other entities). + /// + /// This version of the method considers a cache of information + /// about current BomEntity relationships in this document, as + /// prepared by an earlier call to GetBomRefsWithContainer() and + /// cached by caller (may speed up the loops in case of massive + /// processing). + /// + /// Old value of BomRef + /// New value of BomRef + /// Cached output of earlier GetBomRefsWithContainer(); + /// contents of the cache can change due to successful renaming + /// to keep reflecting BomEntity relations in this document. + /// + /// + /// False if had no hits, had collisions, etc.; + /// True if renamed something without any errors. + /// + /// TODO: throw Exceptions instead of False, + /// to help callers discern the error cases? + /// + public bool RenameBomRef(string oldRef, string newRef, BomWalkResult res) + { + bool somethingModified = false; + + AssertThisBomWalkResult(res); + if (oldRef is null || newRef is null || oldRef == newRef) + { + // Non-fatal, but no-op + // Note: not checking for xxxRef.Trim()=="" or trimmed-string + // equalities as it is up to the caller how things were or + // will be named. + return somethingModified; + } + + if (newRef == "") + { + throw new ArgumentException("newRef is empty, must be at least 1 char"); + } + + // First check if there is anything to rename, and if the name is + // already known as somebody's identifier. + Dictionary dictBomrefs = res.GetBomRefsWithContainer(); + + // At most we have one(!) object with "oldRef" name as its identifier + // (stored as a property of this object): + BomEntity namedObject = null; + BomEntity namedObjectContainer = null; + foreach (var (contained, container) in dictBomrefs) + { + // Here and below: if casting fails and throws... + // it is the right thing to do in given situation :) + object containedBomRef = null; + if (contained is IBomEntityWithRefType_String_BomRef) + { + containedBomRef = ((IBomEntityWithRefType_String_BomRef)contained).GetBomRef(); + } + else + { + var propInfo = contained.GetType().GetProperty("BomRef", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException($"No \"string BomRef\" attribute in class: {contained.GetType().Name}"); + } + containedBomRef = propInfo.GetValue(contained); + } + + if (containedBomRef.ToString() == oldRef) + { + if (namedObject != null) + { + throw new BomEntityConflictException($"Duplicate \"bom-ref\" identifier detected in Bom document, previously under a BomEntity typed {namedObjectContainer.GetType().Name}: {oldRef}"); + } + namedObject = contained; + namedObjectContainer = container; + // Do not "break" the loop, so we can detect dupes and newRef clashes here + } + + if (containedBomRef.ToString() == newRef) + { + throw new ArgumentException($"newRef is already used to name a BomEntity: {newRef}"); + } + } + + // If we got here, the oldRef name exists among + // "contained" entities, and newRef does not. + + // Can proceed with renaming of the item itself (if one exists)...: + if (!(namedObject is null)) + { + bool objectHasStringBomRef = (namedObject is IBomEntityWithRefType_String_BomRef); + + if (!objectHasStringBomRef) + { + // Slower fallback to facilitate faster code evolution + // (with classes not marked as implementors of interfaces) + if (!(BomEntity.KnownEntityTypeProperties.TryGetValue(namedObject.GetType(), out PropertyInfo[] props))) + { + props = namedObject.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); // BindingFlags.DeclaredOnly + } + + foreach (PropertyInfo propInfo in props) + { + if (propInfo.Name == "BomRef" && propInfo.PropertyType == typeof(string)) + { + objectHasStringBomRef = true; + break; + } + } + } + + if (objectHasStringBomRef) + { + object currentRef = null; + PropertyInfo propInfo = null; + if (namedObject is IBomEntityWithRefType_String_BomRef) + { + currentRef = ((IBomEntityWithRefType_String_BomRef)namedObject).GetBomRef(); + } + else + { + propInfo = namedObject.GetType().GetProperty("BomRef", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException($"No \"string BomRef\" attribute in class: {namedObject.GetType().Name}"); + } + currentRef = propInfo.GetValue(namedObject); + } + + if (currentRef.ToString() == oldRef) + { + if (namedObject is IBomEntityWithRefType_String_BomRef) + { + ((IBomEntityWithRefType_String_BomRef)namedObject).SetBomRef(newRef); + } + else + { + propInfo.SetValue(namedObject, newRef); + } + } + else + { + if (currentRef.ToString() != newRef) + { + // Note: "is null" case is also considered an error + throw new BomEntityConflictException($"Object listed as having a \"bom-ref\" identifier, but currently its value does not refer to the old name: {oldRef}"); + } // else? + } + } + else + { + // TODO: Add handling for other use-cases (if any appear as we evolve) + throw new BomEntityIncompatibleException($"Object does not have a \"string BomRef\" property, but was listed as having a \"bom-ref\" identifier: {oldRef}"); + } + } + + // ...and of back-references (if any): + foreach (var (containedRef, referrerList) in res.GetRefsInContainers()) + { + if (containedRef is null || containedRef != oldRef) + { + continue; + } + + // Check each BomEntity known to refer to this "contained" item's name + foreach (var referrer in referrerList) + { + // Track if we had at least one rename + int referrerModified = 0; + + if (referrer is IBomEntityWithRefLinkType_StringList) + { + // In this class, at least one property is a list of strings + // where some item (maybe several in different lists) contains + // the back-reference of interest. + ImmutableDictionary> refLinkConstraints = + ((IBomEntityWithRefLinkType_StringList)referrer).GetRefLinkConstraints(_specVersion); + + foreach (var (referrerPropInfo, allowedTypes) in refLinkConstraints) + { + // NOTE: Here we care about properties in referrer + // class that have (are) suitable lists; constraint + // checks are for diligent validation calls, right?.. + Type propType = referrerPropInfo.PropertyType; + if (!(propType.GetTypeInfo().ImplementedInterfaces.Contains(typeof(System.Collections.IList)))) + { + continue; + } + // TODO: Check if the list contents are string? So far + // just assuming so - due to this class interface. + + // Use cached info where available + PropertyInfo listPropCount = null; + MethodInfo listMethodGetItem = null; + MethodInfo listMethodAdd = null; + if (BomEntity.KnownEntityTypeLists.TryGetValue(propType, out BomEntityListReflection refInfo)) + { + listPropCount = refInfo.propCount; + listMethodGetItem = refInfo.methodGetItem; + listMethodAdd = refInfo.methodAdd; + } + else + { + // No cached info about BomEntityListReflection[propType] + listPropCount = propType.GetProperty("Count"); + listMethodGetItem = propType.GetMethod("get_Item"); + listMethodAdd = propType.GetMethod("Add"); + } + + if (listMethodGetItem == null || listPropCount == null || listMethodAdd == null) + { + // Should not have happened, but... + continue; + } + + // Unlike so many other cases around BomEntity, here + // we know the exact expected class at compile time! + // Hope this is a reference to the same list in the + // BomEntity class object, not a copy etc... + List referrerSubList = (List)referrerPropInfo.GetValue(referrer); + if (referrerSubList != null && referrerSubList.Count > 0) + { + // One of string list items should refer the "contained" entity + // There can be only one (ref with this value in this list)... + bool hadHit = false; + + for (int i = 0; i < referrerSubList.Count; i++) + { + if (referrerSubList[i] == oldRef) + { + if (hadHit) + { + throw new BomEntityConflictException( + "Multiple references to a \"bom-ref\" identifier detected " + + "in the same list of unique items under " + + referrer.GetType() + "." + referrerPropInfo.Name + "[]: " + + oldRef); + } + referrerSubList[i] = newRef; + hadHit = true; + referrerModified++; + } + } + } + } + } + else + { + // Fallback for a few known classes with lists of refs: + Type referrerType = referrer.GetType(); + if (referrerType == typeof(Composition)) + { + // This contains several lists of strings, and + // at most one of string list items in each of + // those should refer the "contained" entity. + + List referrerSubList = ((Composition)referrer).Assemblies; + if (referrerSubList != null && referrerSubList.Count > 0) + { + bool hadHit = false; + + for (int i = 0; i < referrerSubList.Count; i++) + { + if (referrerSubList[i] == oldRef) + { + if (hadHit) + { + throw new BomEntityConflictException( + "Multiple references to a \"bom-ref\" identifier detected " + + "in the same list of unique items under " + + "Composition.Assemblies[]: " + oldRef); + } + referrerSubList[i] = newRef; + hadHit = true; + referrerModified++; + } + } + } + + referrerSubList = ((Composition)referrer).Dependencies; + if (referrerSubList != null && referrerSubList.Count > 0) + { + bool hadHit = false; + + for (int i = 0; i < referrerSubList.Count; i++) + { + if (referrerSubList[i] == oldRef) + { + if (hadHit) + { + throw new BomEntityConflictException( + "Multiple references to a \"bom-ref\" identifier detected " + + "in the same list of unique items under " + + "Composition.Dependencies[]: " + oldRef); + } + referrerSubList[i] = newRef; + hadHit = true; + referrerModified++; + } + } + } + + referrerSubList = ((Composition)referrer).Vulnerabilities; + if (referrerSubList != null && referrerSubList.Count > 0) + { + bool hadHit = false; + + for (int i = 0; i < referrerSubList.Count; i++) + { + if (referrerSubList[i] == oldRef) + { + if (hadHit) + { + throw new BomEntityConflictException( + "Multiple references to a \"bom-ref\" identifier detected " + + "in the same list of unique items under " + + "Composition.Vulnerabilities[]: " + oldRef); + } + referrerSubList[i] = newRef; + hadHit = true; + referrerModified++; + } + } + } + } + + if (referrerType == typeof(EvidenceIdentity)) + { + List referrerSubList = ((EvidenceIdentity)referrer).Tools; + if (referrerSubList != null && referrerSubList.Count > 0) + { + bool hadHit = false; + + for (int i = 0; i < referrerSubList.Count; i++) + { + if (referrerSubList[i] == oldRef) + { + if (hadHit) + { + throw new BomEntityConflictException( + "Multiple references to a \"bom-ref\" identifier detected " + + "in the same list of unique items under " + + "EvidenceIdentity.Tools[]: " + oldRef); + } + referrerSubList[i] = newRef; + hadHit = true; + referrerModified++; + } + } + } + } + } + + // An entity (possibly with a "Ref" property) that directly + // references the "contained" entity. Not an "else" to cater + // for the eventuality that some class would have both some + // list(s) of refs and a "ref" property. + bool referrerHasStringRef = (referrer is IBomEntityWithRefLinkType_String_Ref); + + if (!referrerHasStringRef) + { + // Slower fallback to facilitate faster code evolution + PropertyInfo[] props = + referrer.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); // BindingFlags.DeclaredOnly + foreach (var prop in props) + { + if (prop.Name == "Ref" && prop.PropertyType == typeof(string)) + { + referrerHasStringRef = true; + break; + } + } + } + + if (referrerHasStringRef) + { + object currentRef = null; + PropertyInfo propInfo = null; + if (referrer is IBomEntityWithRefLinkType_String_Ref) + { + currentRef = ((IBomEntityWithRefLinkType_String_Ref)referrer).GetRef(); + } + else + { + propInfo = referrer.GetType().GetProperty("Ref", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException( + "No \"string Ref\" attribute in class: " + + referrer.GetType().Name); + } + currentRef = propInfo.GetValue(referrer); + } + + if (currentRef.ToString() == oldRef) + { + if (referrer is IBomEntityWithRefLinkType_String_Ref) + { + ((IBomEntityWithRefLinkType_String_Ref)referrer).SetRef(newRef); + } + else + { + propInfo.SetValue(referrer, newRef); + } + referrerModified++; + } + else + { + if (currentRef.ToString() == newRef) + { + // We had no conflicts before, so must have achieved + // this via several clones of a referrer?.. + referrerModified++; + } + else + { + throw new BomEntityConflictException( + "Object listed as having a reference to a \"bom-ref\" identifier, " + + "but currently its ref does not refer to the old name: " + oldRef); + } + } + } + else + { + // Was it fixed-up as an object with lists, at least?.. + if (referrerModified == 0) + { + // TODO: Add handling for other use-cases (if any appear as we evolve) + throw new BomEntityIncompatibleException( + "Object does not have a \"string Ref\" or a suitable " + + "list of strings property, but was listed as having " + + "a reference to a \"bom-ref\" identifier: " + oldRef); + } + } + + somethingModified |= (referrerModified == 0); + } + } + + // Survived without exceptions! ;) + return somethingModified; + } + + /// + /// See related method + /// RenameBomRef(string oldRef, string newRef, Dictionary dict) + /// for details. + /// + /// This version of the method prepares and discards the helper + /// dictionary with mapping of cross-referencing entities, and + /// is easier to use in code for single-use cases but is less + /// efficient for massive processing loops. + /// + /// Old value of BomRef + /// New value of BomRef + /// False if had no hits; True if renamed something without any errors + public bool RenameBomRef(string oldRef, string newRef) + { + BomWalkResult res = WalkThis(); + return this.RenameBomRef(oldRef, newRef, res); + } } -} \ No newline at end of file +} diff --git a/src/CycloneDX.Core/Models/BomEntity.cs b/src/CycloneDX.Core/Models/BomEntity.cs new file mode 100644 index 00000000..40ffd385 --- /dev/null +++ b/src/CycloneDX.Core/Models/BomEntity.cs @@ -0,0 +1,2186 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; +using System.Xml; + +namespace CycloneDX.Models +{ + [Serializable] + public class BomEntityConflictException : Exception + { + public BomEntityConflictException() + : base("Unresolvable conflict in Bom entities") + { } + + public BomEntityConflictException(Type type) + : base(String.Format("Unresolvable conflict in Bom entities of type {0}", type)) + { } + + public BomEntityConflictException(string msg) + : base(String.Format("Unresolvable conflict in Bom entities: {0}", msg)) + { } + + public BomEntityConflictException(string msg, Type type) + : base(String.Format("Unresolvable conflict in Bom entities of type {0}: {1}", type, msg)) + { } + } + + [Serializable] + public class BomEntityIncompatibleException : Exception + { + public BomEntityIncompatibleException() + : base("Comparing incompatible Bom entities") + { } + + public BomEntityIncompatibleException(Type type1, Type type2) + : base(String.Format("Comparing incompatible Bom entities of types {0} and {1}", type1, type2)) + { } + + public BomEntityIncompatibleException(string msg) + : base(String.Format("Comparing incompatible Bom entities: {0}", msg)) + { } + + public BomEntityIncompatibleException(string msg, Type type1, Type type2) + : base(String.Format("Comparing incompatible Bom entities of types {0} and {1}: {2}", type1, type2, msg)) + { } + } + + /// + /// Global configuration helper for ListMergeHelper, + /// BomEntityListMergeHelper, Merge.cs implementations + /// and related codebase. + /// + public class BomEntityListMergeHelperStrategy + { + /// + /// Cause ListMergeHelper to consider calling + /// the BomEntityListMergeHelper->Merge which in + /// turn calls BomEntity->MergeWith() in a loop, + /// vs. just comparing entities for equality and + /// deduplicating based on that (goes faster but + /// may cause data structure not conforming to spec) + /// + public bool useBomEntityMerge { get; set; } + + /// + /// When merging whole Bom documents which include + /// Equivalent() Components (and probably references + /// back to them in respective Dependencies[] lists) + /// with differing values of Scope (required or null + /// vs. optional vs. excluded), do not conflate them + /// but instead rename the two siblings' values of + /// "bom-ref", suffixing the ":scope" - including + /// the back-references from locations known by spec. + /// Also consider equality of non-null Dependencies + /// pointing back to their same BomRef value in the + /// two original Bom documents (notably honouring the + /// explicitly empty "dependsOn" lists -- NOT NULL). + /// + /// This is partially orthogonal to useBomEntityMerge + /// setting which would allow to populate missing + /// data points using an incoming Component object: + /// * "partially" being that when two Components would + /// be inspected by MergeWith(), the possibiliy of + /// such suffix would be considered among equality + /// criteria (not exact equality of BomRef props). + /// * "orthogonal" relating to the fact that this conflict + /// inspection aims to be a quick pre-processing stage + /// similar to quick merge (useBomEntityMerge==false) + /// and modifies the incoming list of Bom documents + /// before that quick merge, with a targeted solution + /// cheaper than a full MergeWith() iteration. + /// + /// This is a bit costlier in processing, but safer in + /// pedantic approach, than the known alternatives: + /// * Just following "useBomEntityMerge" to the letter, + /// comparing for exact equality of serialization of + /// the two objects -- two or more copies of the same + /// BomRef value assigned to different but related + /// "real-life" entities can appear (e.g. when "scope" + /// differs, like for production and testing modules) + /// AND different Dependencies[] entries can exist + /// (e.g. different Maven resolutions when building + /// a Java ecosystem library vs. an app using it, + /// with different dependencyManagement preferences). + /// Due to this, we can not quickly conflate "purely + /// equal" entities as the first pass when such + /// nuanced inequalities can arise. + /// * Brutely conflating the Components with different + /// Scopes ("optional" becomes "required" if something + /// else in the overall merged product did require it) + /// can backfire if the merged document describes an + /// end-user bundle of a number of products: their + /// separate programs (or even containers) do still have + /// their separate dependency trees, so "app A" requiring + /// a library does not mean that "app B" which had it as + /// optional suddenly requires it now -- and maybe gets + /// false-positive vulnerabilities reported due to that. + /// For merged Bom documents describing a single linker + /// namespace such conflation may in fact be valid however. + /// + public bool renameConflictingComponents { get; set; } + + /// + /// CycloneDX spec version. + /// + public SpecificationVersion specificationVersion { get; set; } + + /// + /// Used by interim Merge.FlatMerge(bom1, bom2) in a loop + /// context -- defaulting to `false` to reduce compute + /// load for results we would discard. Can be set to `true` + /// by some other use-cases that would invoke that method. + /// Does not impact the Merge.FlatMerge(Iterable) variant. + /// + /// See also: doBomMetadataUpdateNewSerialNumber, + /// doBomMetadataUpdateReferThisToolkit + /// + public bool doBomMetadataUpdate { get; set; } + /// See doBomMetadataUpdate description. + public bool doBomMetadataUpdateNewSerialNumber { get; set; } + /// See doBomMetadataUpdate description. + public bool doBomMetadataUpdateReferThisToolkit { get; set; } + + /// + /// Return reasonable default strategy settings. + /// + /// A new ListMergeHelperStrategy instance + /// which the callers can tune to their liking. + public static BomEntityListMergeHelperStrategy Default() + { + return new BomEntityListMergeHelperStrategy + { + useBomEntityMerge = true, + renameConflictingComponents = true, + doBomMetadataUpdate = false, + doBomMetadataUpdateNewSerialNumber = false, + doBomMetadataUpdateReferThisToolkit = false, + specificationVersion = SpecificationVersionHelpers.CurrentVersion + }; + } + } + + public class BomEntityListMergeHelper where T : BomEntity + { + public List Merge(List list1, List list2, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + if (!int.TryParse(System.Environment.GetEnvironmentVariable("CYCLONEDX_DEBUG_MERGE"), out int iDebugLevel) || iDebugLevel < 0) + { + iDebugLevel = 0; + } + + // Rule out utterly empty inputs + if ((list1 is null || list1.Count < 1) && (list2 is null || list2.Count < 1)) + { + if (!(list1 is null)) + { + return list1; + } + if (!(list2 is null)) + { + return list2; + } + return new List(); + } + + List result = new List(); + + // Note: no blind checks for null/empty inputs - part of logic below, + // in order to surely de-duplicate even single incoming lists. + if (!listMergeHelperStrategy.useBomEntityMerge) + { + // Most BomEntity classes are not individually IEquatable to avoid the + // copy-paste coding overhead, however they inherit the Equals() and + // GetHashCode() methods from their base class. + if (iDebugLevel >= 1) + { + Console.WriteLine($"List-Merge (quick and careless) for BomEntity-derived types: {list1?.GetType()?.ToString()} and {list2?.GetType()?.ToString()}"); + } + + List hashList = new List(); + List hashList2 = new List(); + + // Exclude possibly pre-existing identical entries first, then similarly + // handle data from the second list. Here we have the "benefit" of lack + // of real content merging, so already saved items (and their hashes) + // can be treated as immutable. + if (!(list1 is null) && list1.Count > 0) + { + foreach (T item1 in list1) + { + if (item1 is null) + { + continue; + } + int hash1 = item1.GetHashCode(); + if (hashList.Contains(hash1)) + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"LIST-MERGE: hash table claims duplicate data in original list1: ${item1.SerializeEntity()}"); + } + continue; + } + result.Add(item1); + hashList.Add(hash1); + } + } + + if (!(list2 is null) && list2.Count > 0) + { + foreach (T item2 in list2) + { + if (item2 is null) + { + continue; + } + int hash2 = item2.GetHashCode(); + + // For info (track if data is bad or hash is unreliably weak): + if (iDebugLevel >= 1) + { + if (hashList2.Contains(hash2)) + { + Console.WriteLine($"LIST-MERGE: hash table claims duplicate data in original list2: ${item2.SerializeEntity()}"); + } + hashList2.Add(hash2); + } + + if (hashList.Contains(hash2)) + { + continue; + } + result.Add(item2); + hashList.Add(hash2); + } + } + + return result; + } + + // Here both lists are assumed to possibly have same or equivalent + // entries, even inside the same original list (e.g. if prepared by + // quick logic above for de-duplicating the major bulk of content). + Type TType = ((!(list1 is null) && list1.Count > 0) ? list1[0] : list2[0]).GetType(); + + if (iDebugLevel >= 1) + { + Console.WriteLine($"List-Merge (careful) for BomEntity derivatives: {TType.ToString()}"); + } + + if (!BomEntity.KnownTypeMergeWith.TryGetValue(TType, out var methodMergeWith)) + { + methodMergeWith = null; + } + + // Compact version of loop below; see comments there. + // In short, we avoid making a plain copy of list1 so + // we can carefully pass each entry to MergeWith() + // any suitable other in the same original list. + if (!(list1 is null) && list1.Count > 0) + { + foreach (var item0 in list1) + { + bool resMerge = false; + for (int i=0; i < result.Count; i++) + { + var item1 = result[i]; + if (methodMergeWith != null) + { + resMerge = (bool)methodMergeWith.Invoke(item1, new object[] {item0, listMergeHelperStrategy}); + } + else + { + resMerge = item1.MergeWith(item0, listMergeHelperStrategy); + } + + if (resMerge) + { + break; // item2 merged into result[item1] or already equal to it + } + } + + if (!resMerge) + { + result.Add(item0); + } + } + } + + // Similar logic to the pass above, but with optional logging to + // highlight results of merges of the second list into the first. + if (!(list2 is null) && list2.Count > 0) + { + foreach (var item2 in list2) + { + bool isContained = false; + if (iDebugLevel >= 3) + { + Console.WriteLine($"result<{TType.ToString()}> now contains {result.Count} entries"); + } + + for (int i=0; i < result.Count; i++) + { + if (iDebugLevel >= 3) + { + Console.WriteLine($"result<{TType.ToString()}>: checking entry #{i}"); + } + var item1 = result[i]; + + // Squash contents of the new entry with an already + // existing equivalent (same-ness is subject to + // IEquatable<>.Equals() checks defined in respective + // classes), if there is a method defined there. + // For BomEntity descendant instances we assume that + // they have Equals(), Equivalent() and MergeWith() + // methods defined or inherited as is suitable for + // the particular entity type, hence much less code + // and error-checking than there was in the PoC: + bool resMerge; + if (methodMergeWith != null) + { + resMerge = (bool)methodMergeWith.Invoke(item1, new object[] {item2, listMergeHelperStrategy}); + } + else + { + resMerge = item1.MergeWith(item2, listMergeHelperStrategy); + } + // MergeWith() may throw BomEntityConflictException which we + // want to propagate to users - their input data is confusing. + // Probably should not throw BomEntityIncompatibleException + // unless the lists truly are of mixed types. + + if (resMerge) + { + isContained = true; + break; // item2 merged into result[item1] or already equal to it + } + } + + if (isContained) + { + if (iDebugLevel >= 2) + { + Console.WriteLine($"ALREADY THERE: {item2.ToString()}"); + } + } + else + { + // Add new entry "as is" (new-ness is subject to + // equality checks of respective classes): + if (iDebugLevel >= 2) + { + Console.WriteLine($"WILL ADD: {item2.ToString()}"); + } + result.Add(item2); + } + } + } + + return result; + } + } + + public class BomEntityListReflection + { + public Type genericType { get; set; } + public PropertyInfo propCount { get; set; } + public MethodInfo methodAdd { get; set; } + public MethodInfo methodAddRange { get; set; } + public MethodInfo methodGetItem { get; set; } + public MethodInfo methodSort { get; set; } + public MethodInfo methodReverse { get; set; } + } + + public class BomEntityListMergeHelperReflection + { + public Type genericType { get; set; } + public MethodInfo methodMerge { get; set; } + public Object helperInstance { get; set; } + } + + /// + /// Just a baseline interface for the big BomEntity + /// family to formally implement. In practice all + /// those classes are derived from BomEntity so it + /// can dispatch calls into them when used as a + /// generic base class, or serve default method + /// implementations. + /// + public interface IBomEntity : IEquatable + { + public string SerializeEntity(); + } + + /// + /// Interface assigned to BomEntity derived classes + /// which have a property generally conforming to + /// CycloneDX schema definition of "bom:refType" + /// (per XML schema) or "#/definitions/refType" + /// (per JSON schema). + /// + /// Such a property is usually called "bom-ref" + /// in text representations of Bom documents and + /// is a C# string; however some more complex type + /// may be used in the future to multi-plex all the + /// different referencing use-cases. + /// + /// For specific practical hints, see also: + /// IBomEntityWithRefType_String_BomRef + /// + public interface IBomEntityWithRefType : IBomEntity + { + } + + /// + /// Interface assigned to BomEntity derived classes + /// which have a property with a CycloneDX Bom schema + /// "refType" attribute specifically named "BomRef" + /// and typed as a "string" in C#. It helps to know + /// where we can call GetBomRef() safely... + /// + public interface IBomEntityWithRefType_String_BomRef : IBomEntityWithRefType + { + public string GetBomRef(); + public void SetBomRef(string s); + } + + /// + /// Interface assigned to BomEntity derived classes + /// which have a property generally conforming to + /// CycloneDX schema definition of + /// "bom:refLinkType" (per XML schema) or + /// "#/definitions/refLinkType" (per JSON schema). + /// Such a property is usually called "ref" + /// in text representations of Bom documents, + /// but can be items in certain lists as well. + /// + /// Technically it follows same schema definition + /// as a "refType" but is intended (since CDX 1.5) + /// to specify links pointing to someone else's + /// "bom-ref" values. + /// + /// For specific practical hints, see also: + /// IBomEntityWithRefLinkType_String_Ref + /// IBomEntityWithRefLinkType_StringList + /// + public interface IBomEntityWithRefLinkType : IBomEntity + { + /// + /// For each property in this class which can + /// convey a Bom "refLinkType" (single values + /// like a "ref" or lists full of references), + /// clarify which classes are expected to be + /// on the other end of the reference -- with + /// one of their instances having the "bom-ref" + /// identification value specified in this "ref". + /// The CycloneDX spec details that some refs + /// only point to a "component", others also + /// to a "service", some to a "componentData", + /// and some do not constrain. + /// + /// Note that there may be no hits in the + /// current Bom document, and not all items + /// with a "bom-ref" attribute would have + /// such back-links to them defined in the + /// same Bom document. + /// + /// + // FIXME: Would a C# annotation serve this cause + // better? Would it be faster in processing + // (with reflection) e.g. to *find* which + // properties to look at? + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion); + } + + /// + /// Interface assigned to BomEntity derived classes + /// which have a property with a CycloneDX Bom schema + /// "refLinkType" attribute specifically named "Ref" + /// and typed as a "string" in C#. It helps to know + /// where we can call GetRef() safely... + /// + public interface IBomEntityWithRefLinkType_String_Ref : IBomEntityWithRefLinkType + { + public string GetRef(); + public void SetRef(string s); + } + + /// + /// Interface assigned to BomEntity derived classes + /// which have one or more properties which are lists, + /// whose items conform to CycloneDX Bom schema for + /// "refLinkType", and are typed as a "List" + /// in C#. It helps to know where we can iterate + /// those safely... See also GetRefLinkConstraints(). + /// + public interface IBomEntityWithRefLinkType_StringList : IBomEntityWithRefLinkType + { + } + + /// + /// BomEntity is intended as a base class for other classes in CycloneDX.Models, + /// which in turn encapsulate different concepts and data types described by + /// the specification. It allows them to share certain behaviors such as the + /// ability to determine "equivalent but not equal" objects (e.g. two instances + /// of a Component with the same "bom-ref" but different in some properties), + /// and to define the logic for merge-ability of such objects while coding much + /// of the logical scaffolding only once. + /// + public class BomEntity : IBomEntity + { + // Keep this info initialized once to cut down on overheads of reflection + // when running in our run-time loops. + // Thanks to https://stackoverflow.com/a/45896403/4715872 for the Func'y trick + // and https://stackoverflow.com/questions/857705/get-all-derived-types-of-a-type + // TOTHINK: Should these be exposed as public or hidden even more strictly? + // Perhaps add getters for a copy? + + /// + /// List of classes derived from BomEntity, prepared startically at start time. + /// + public static readonly ImmutableList KnownEntityTypes = + new Func>(() => + { + List derived_types = new List(); + foreach (var domain_assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + var assembly_types = domain_assembly.GetTypes() + .Where(type => type.IsSubclassOf(typeof(BomEntity)) && !type.IsAbstract); + + derived_types.AddRange(assembly_types); + } + return ImmutableList.Create(derived_types.ToArray()); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about their custom Equals() method implementations + /// (if present), prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownEntityTypeProperties = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + dict[type] = type.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); // BindingFlags.DeclaredOnly + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + public static readonly ImmutableDictionary KnownEntityTypeLists = + new Func>(() => + { + Dictionary dict = new Dictionary(); + List KnownEntityTypesPlus = new List(KnownEntityTypes); + KnownEntityTypesPlus.Add(typeof(BomEntity)); + foreach (var type in KnownEntityTypesPlus) + { + // Inspired by https://stackoverflow.com/a/4661237/4715872 + // to craft a List "result" at run-time: + Type listType = typeof(List<>); + Type constructedListType = listType.MakeGenericType(type); + // Would we want to stach a pre-created helper instance as Activator.CreateInstance(constructedListType) ? + + dict[type] = new BomEntityListReflection(); + dict[type].genericType = constructedListType; + + // Gotta use reflection for run-time evaluated type methods: + dict[type].propCount = constructedListType.GetProperty("Count"); + dict[type].methodGetItem = constructedListType.GetMethod("get_Item"); + dict[type].methodAdd = constructedListType.GetMethod("Add", 0, new [] { type }); + dict[type].methodAddRange = constructedListType.GetMethod("AddRange", 0, new [] { constructedListType }); + + // Use the default no-arg implementations here explicitly, + // to avoid an System.Reflection.AmbiguousMatchException: + dict[type].methodSort = constructedListType.GetMethod("Sort", 0, new Type[] {}); + dict[type].methodReverse = constructedListType.GetMethod("Reverse", 0, new Type[] {}); + + // Avoid: No cached info about BomEntityListReflection[System.Collections.Generic.List`1[CycloneDX.Models.ExternalReference]] + // TODO: Separate dict?.. + dict[constructedListType] = dict[type]; + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + public static readonly ImmutableDictionary KnownBomEntityListMergeHelpers = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + // Inspired by https://stackoverflow.com/a/4661237/4715872 + // to craft a List "result" at run-time: + Type listHelperType = typeof(BomEntityListMergeHelper<>); + Type constructedListHelperType = listHelperType.MakeGenericType(type); + var helper = Activator.CreateInstance(constructedListHelperType); + Type LType = null; + if (KnownEntityTypeLists.TryGetValue(type, out BomEntityListReflection refInfo)) + { + LType = refInfo.genericType; + } + + if (LType != null) + { + // Gotta use reflection for run-time evaluated type methods: + var methodMerge = constructedListHelperType.GetMethod("Merge", 0, new [] { LType, LType, typeof(BomEntityListMergeHelperStrategy) }); + if (methodMerge != null) + { + dict[type] = new BomEntityListMergeHelperReflection(); + dict[type].genericType = constructedListHelperType; + dict[type].methodMerge = methodMerge; + dict[type].helperInstance = helper; + // Callers would return something like (List)methodMerge.Invoke(helper, new object[] {list1, list2}) + } + else + { + // Should not get here, but if we do - make noise + throw new InvalidOperationException($"BomEntityListMergeHelper<{type}> lacks a Merge() helper method"); + } + } + else + { + throw new InvalidOperationException($"BomEntityListMergeHelper<{type}> lacks a List class definition"); + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about custom CycloneDX.Json.Serializer.Serialize() + /// implementations (if present), prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownTypeSerializers = + new Func>(() => + { + var jserClassType = typeof(CycloneDX.Json.Serializer); + var methodDefault = jserClassType.GetMethod("Serialize", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic, + new [] { typeof(BomEntity) }); + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + var method = jserClassType.GetMethod("Serialize", + BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly, + new [] { type }); + if (method != null && method != methodDefault) + { + dict[type] = method; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about their custom Equals() method implementations + /// (if present), prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownTypeEquals = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + var method = type.GetMethod("Equals", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type }); + if (method != null) + { + dict[type] = method; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + public static readonly ImmutableDictionary KnownDefaultEquals = + new Func>(() => + { + Dictionary dict = new Dictionary(); + var methodDefault = typeof(BomEntity).GetMethod("Equals", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { typeof(BomEntity) }); + foreach (var type in KnownEntityTypes) + { + var method = type.GetMethod("Equals", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type }); + if (method == null) + { + dict[type] = methodDefault; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + // Our loops check for some non-BomEntity typed value equalities, + // so cache their methods if present. Note that this one retains + // the "null" results to mark that we do not need to look further. + public static readonly ImmutableDictionary KnownOtherTypeEquals = + new Func>(() => + { + Dictionary dict = new Dictionary(); + var listMore = new List(); + listMore.Add(typeof(string)); + listMore.Add(typeof(bool)); + listMore.Add(typeof(int)); + foreach (var type in listMore) + { + var method = type.GetMethod("Equals", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type }); + dict[type] = method; + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about their custom Equivalent() method implementations + /// (if present), prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownTypeEquivalent = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + var method = type.GetMethod("Equivalent", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type }); + if (method != null) + { + dict[type] = method; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + public static readonly ImmutableDictionary KnownDefaultEquivalent = + new Func>(() => + { + Dictionary dict = new Dictionary(); + var methodDefault = typeof(BomEntity).GetMethod("Equivalent", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { typeof(BomEntity) }); + foreach (var type in KnownEntityTypes) + { + var method = type.GetMethod("Equivalent", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type }); + if (method == null) + { + dict[type] = methodDefault; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about their custom MergeWith() method implementations + /// (if present), prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownTypeMergeWith = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + var method = type.GetMethod("MergeWith", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, + new [] { type, typeof(BomEntityListMergeHelperStrategy) }); + if (method != null) + { + dict[type] = method; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// + /// Dictionary mapping classes derived from BomEntity to reflection + /// MethodInfo about their custom static NormalizeList() method + /// implementations (if present) for sorting=>normalization of lists + /// of that BomEntity-derived type, prepared startically at start time. + /// + public static readonly ImmutableDictionary KnownTypeNormalizeList = + new Func>(() => + { + Dictionary dict = new Dictionary(); + foreach (var type in KnownEntityTypes) + { + MethodInfo method = null; + + if (BomEntity.KnownEntityTypeLists.TryGetValue(type, out BomEntityListReflection refInfoListType)) + { + method = type.GetMethod("NormalizeList", + BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly, + new [] { typeof(bool), typeof(bool), refInfoListType.genericType }); + if (method != null) + { + dict[type] = method; + continue; + } + } + + // Try class default + method = type.GetMethod("NormalizeList", + BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly, + new [] { typeof(bool), typeof(bool), typeof(List) }); + if (method != null) + { + dict[type] = method; + } + } + return ImmutableDictionary.CreateRange(dict); + }) (); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_AnyBomEntity = new List {typeof(CycloneDX.Models.BomEntity)}.ToImmutableList(); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_Component = new List {typeof(CycloneDX.Models.Component)}.ToImmutableList(); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_Service = new List {typeof(CycloneDX.Models.Service)}.ToImmutableList(); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_ComponentOrService = new List {typeof(CycloneDX.Models.Component), typeof(CycloneDX.Models.Service)}.ToImmutableList(); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_ModelDataset = new List {typeof(CycloneDX.Models.Data)}.ToImmutableList(); + + /// Used by IBomEntityWithRefLinkType.GetRefLinkConstraints() in some descendant classes. + public static readonly ImmutableList RefLinkConstraints_Vulnerability = new List {typeof(CycloneDX.Models.Vulnerabilities.Vulnerability)}.ToImmutableList(); + + protected BomEntity() + { + // a bad alternative to private could be to: throw new NotImplementedException("The BomEntity class directly should not be instantiated") + } + + /// + /// Helper for comparisons and getting object hash code. + /// Calls our standard CycloneDX.Json.Serializer to use + /// its common options in particular. + /// + public string SerializeEntity() + { + // Do we have a custom serializer defined? Use it! + // (One for BomEntity tends to serialize this base class + // so comes up empty, or has to jump through hoops...) + Type thisType = this.GetType(); + if (KnownTypeSerializers.TryGetValue(thisType, out var methodSerializeThis)) + { + var res1 = (string)methodSerializeThis.Invoke(null, new object[] {this}); + return res1; + } + + var res = CycloneDX.Json.Serializer.SerializeCompact(this); + return res; + } + + /// + /// NOTE: Class methods do not "override" this one because they compare to their type + /// and not to the base BomEntity type objects. They should also not call this method + /// to avoid looping - implement everything needed there directly, if ever needed! + /// Keep in mind that the base implementation calls the SerializeEntity() method which + /// should be by default aware and capable of ultimately serializing the properties + /// relevant to each derived class. + /// + /// Another BomEntity-derived object of same type + /// True if two objects are deemed equal + public bool Equals(IBomEntity obj) + { + Type thisType = this.GetType(); + if (KnownTypeEquals.TryGetValue(thisType, out var methodEquals)) + { + return (bool)methodEquals.Invoke(this, new object[] {obj}); + } + + if (obj is null || thisType != obj.GetType()) + { + return false; + } + return this.SerializeEntity() == obj.SerializeEntity(); + } + + // Needed by IEquatable contract + public override bool Equals(Object obj) + { + if (obj is null || !(obj is BomEntity)) + { + return false; + } + return this.Equals((BomEntity)obj); + } + + /// + /// Returns hash code of the string returned by + /// `this.SerializeEntity()` (typically a compact + /// JSON representation) plus the length of this + /// string to randomize it a bit against hash + /// collisions. Never saw those, but just in case. + /// + /// Int hash code + public override int GetHashCode() + { + string ser = this.SerializeEntity(); + return ser.GetHashCode() + ser.Length; + } + + /// + /// Do this and other objects describe the same real-life entity? + /// "Override" this in sub-classes that have a more detailed definition of + /// equivalence (e.g. that certain fields are equal even if whole contents + /// are not) by defining an implementation tailored to that derived type + /// as the argument, or keep this default where equiality is equivalence. + /// + /// Another object of same type + /// True if two data objects are considered to represent + /// the same real-life entity, False otherwise. + public bool Equivalent(BomEntity obj) + { + Type thisType = this.GetType(); + if (KnownTypeEquivalent.TryGetValue(thisType, out var methodEquivalent)) + { + // Note we do not check for null/type of "obj" at this point + // since the derived classes define the logic of equivalence + // (possibly to other entity subtypes as well). + return (bool)methodEquivalent.Invoke(this, new object[] {obj}); + } + + // Note that here a default Equivalent() may call into custom Equals(), + // so the similar null/type sanity shecks are still relevant. + return (!(obj is null) && (thisType == obj.GetType()) && this.Equals(obj)); + } + + /// + /// In-place normalization of a list of BomEntity-derived type. + /// Derived classes can implement this as a sort by one or more + /// of specific properties (e.g. name or bom-ref). Note that + /// handling of the "recursive" option is commonly handled in + /// the base-class method, via which these should be called. + /// Being a static method, those in derived classes are not + /// overrides for the PoV of the language. + /// + /// Ordering proposed in these methods is an educated guess. + /// Main purpose for this is to have some consistently ordered + /// serialized BomEntity lists for the purposes of comparison + /// and compression. + /// + /// TODO: this should really be offloaded as lambdas into the + /// BomEntity-derived classes themselves, but I've struggled + /// to cast the right magic spells at C# to please its gods. + /// In particular, the ValueTuple used in selector signature is + /// both generic for the values' types (e.g. ), + /// and for their amount in the tuple (0, 1, 2, ... explicitly + /// stated). So this is the next best thing... even if highly + /// inefficient to copy lists from one type to another as a + /// fake cast. At least it works!.. + /// + public static void NormalizeList(bool ascending, bool recursive, List list) + { + if (list is null || list.Count < 2) + { + // No-op quickly for null, empty or single-item lists + return; + } + + Type thisType = list[0].GetType(); + + if (recursive) + { + // Look into properties of each currently listed BomEntity-derived + // type instance, so if there are further lists - sort them similarly. + PropertyInfo[] properties = BomEntity.KnownEntityTypeProperties[thisType]; + foreach (PropertyInfo property in properties) + { + if (property.PropertyType == typeof(List) || property.PropertyType.ToString().StartsWith("System.Collections.Generic.List")) + { + // Re-use these learnings while we iterate all original + // list items regarding the specified sub-list property: + Type LType = null; + Type TType = null; + PropertyInfo propCount = null; + MethodInfo methodGetItem = null; + MethodInfo methodSort = null; + MethodInfo methodReverse = null; + MethodInfo methodNormalizeSubList = null; + bool retryMethodNormalizeSubList = true; + + foreach(var obj in list) + { + if (obj is null) + { + continue; + } + + try + { + // Use cached info where available for all the + // list and list-item types and methods involved. + + // Get the (list) property of the originally iterated + // BomEntity-derived item from our original list: + var propValObj = property.GetValue(obj); + + // Is that sub-list trivial enough to skip? + if (propValObj is null) + { + continue; + } + + if (LType == null) + { + LType = propValObj.GetType(); + } + + // Learn how to query that LType sort of lists: + if (methodGetItem == null || propCount == null || methodSort == null || methodReverse == null) + { + if (BomEntity.KnownEntityTypeLists.TryGetValue(LType, out BomEntityListReflection refInfo)) + { + propCount = refInfo.propCount; + methodGetItem = refInfo.methodGetItem; + methodSort = refInfo.methodSort; + methodReverse = refInfo.methodReverse; + } + else + { + propCount = LType.GetProperty("Count"); + methodGetItem = LType.GetMethod("get_Item"); + methodSort = LType.GetMethod("Sort"); + methodReverse = LType.GetMethod("Reverse"); + } + + if (methodGetItem == null || propCount == null || methodSort == null || methodReverse == null) + { + // is this really a LIST - it lacks a get_Item() or other methods, or a Count property + continue; + } + } + + // Is that sub-list trivial enough to skip? + int propValObjCount = (int)propCount.GetValue(propValObj, null); + if (propValObjCount < 2) + { + continue; + } + + // Type of items in that sub-list: + if (TType == null) + { + TType = methodGetItem.Invoke(propValObj, new object[] { 0 }).GetType(); + } + + // Learn how to sort the sub-list of those item types: + if (methodNormalizeSubList == null && retryMethodNormalizeSubList) + { + if (!KnownTypeNormalizeList.TryGetValue(TType, out var methodNormalizeSubListTmp)) + { + methodNormalizeSubListTmp = null; + retryMethodNormalizeSubList = false; + } + methodNormalizeSubList = methodNormalizeSubListTmp; + } + + if (methodNormalizeSubList != null) + { + // call static NormalizeList(..., List obj.propValObj) + methodNormalizeSubList.Invoke(null, new object[] {ascending, recursive, propValObj}); + } + else + { + // Default-sort a common sub-list directly (no recursion) + methodSort.Invoke(propValObj, null); + if (!ascending) + { + methodReverse.Invoke(propValObj, null); + } + } + } + catch (System.InvalidOperationException) + { + // property.GetValue(obj) failed + } + catch (System.Reflection.TargetInvocationException) + { + // property.GetValue(obj) failed + } + } + } + } + } + + if (KnownTypeNormalizeList.TryGetValue(thisType, out var methodNormalizeList)) + { + if (BomEntity.KnownEntityTypeLists.TryGetValue(thisType, out BomEntityListReflection refInfoListType)) + { + if (BomEntity.KnownEntityTypeLists.TryGetValue(typeof(BomEntity), out BomEntityListReflection refInfoListInterface)) + { + // Note we do not check for null/type of "obj" at this point + // since the derived classes define the logic of equivalence + // (possibly to other entity subtypes as well). + // methodNormalizeList.Invoke(null, new object[] {ascending, recursive, list}) does + // not work, alas + + // Gotta make ugly cast copies there and back: + var helper = Activator.CreateInstance(refInfoListType.genericType); + foreach (var item in list) + { + refInfoListType.methodAdd.Invoke(helper, new object[] {item}); + } + + methodNormalizeList.Invoke(null, new object[] {ascending, recursive, helper}); + + // Populate back the original list object: + list.Clear(); + refInfoListInterface.methodAddRange.Invoke(list, new [] {helper}); + return; + } + } + } + + // Expensive but reliable default implementation (modulo differently + // sorted lists of identical item sets inside the otherwise identical + // objects -- but currently spec seems to mean ordered collections), + // classes are welcome to implement theirs eventually or switch cases + // above currently. + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.SerializeEntity()), + null); + } + + /// + /// See NormalizeList(); this variant defaults "ascending=true" + /// + /// Should this recurse into child-object properties which are sub-lists? + /// + public static void NormalizeList(bool recursive, List list) + { + NormalizeList(true, recursive, list); + } + + /// + /// + /// See NormalizeList(); this variant defaults "ascending=true" + /// and "recursive=false" to only normalize the given list itself. + /// + /// + public static void NormalizeList(List list) + { + NormalizeList(true, false, list); + } + + /// Default implementation just "agrees" that Equals()==true objects + /// are already merged (returns true), and that Equivalent()==false + /// objects are not (returns false), and for others (equivalent but + /// not equal, or different types) raises an exception. + /// Treats a null "other" object as a success (it is effectively a + /// no-op merge, which keeps "this" object as is). + /// + /// Another object of same type whose additional + /// non-conflicting data we try to squash into this object. + /// A BomEntityListMergeHelperStrategy + /// instance which relays nuances about desired merging activity. + /// True if merge was successful, False if it these objects + /// are not equivalent, or throws if merge can not be done (including + /// lack of merge logic or unresolvable conflicts in data points). + /// + /// Source data problem: two entities with conflicting information + /// Caller error: somehow merging different entity types + public bool MergeWith(BomEntity obj, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + if (obj is null) + { + return true; + } + if (this.GetType() != obj.GetType()) + { + // Note: potentially descendent classes can catch this + // to adapt their behavior... if some two different + // classes would ever describe something comparable + // in real life. + throw new BomEntityIncompatibleException(this.GetType(), obj.GetType()); + } + + if (this.Equals(obj)) + { + return true; + } + // Avoid calling Equals => serializer twice for no gain + // (default equivalence is equality): + if (KnownTypeEquivalent.TryGetValue(this.GetType(), out var methodEquivalent)) + { + if (!this.Equivalent(obj)) + { + return false; + } + // else fall through to exception below + } + else + { + return false; // known not equal => not equivalent by default => false + } + + // Normal mode of operation: descendant classes catch this + // exception to use their custom non-trivial merging logic. + throw new BomEntityConflictException( + "Base-method implementation treats equivalent but not equal entities as conflicting", + this.GetType()); + } + + /// + /// Default implementation for derived classes which implement IBomEntityWithRefType + /// + /// + public string GetBomRef() + { + if (this is IBomEntityWithRefType) + { + Type thisType = this.GetType(); + PropertyInfo propInfo = thisType.GetProperty("BomRef", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException("No \"string BomRef\" attribute in class: " + thisType.Name); + } + return (string)propInfo.GetValue(this); + } + + return null; + } + + /// + /// Default implementation for derived classes which implement IBomEntityWithRefType + /// + /// + public void SetBomRef(string s) + { + if (this is IBomEntityWithRefType) + { + Type thisType = this.GetType(); + PropertyInfo propInfo = thisType.GetProperty("BomRef", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException("No \"string BomRef\" attribute in class: " + thisType.Name); + } + propInfo.SetValue(this, s); + } + } + + /// + /// Default implementation for derived classes which implement IBomEntityWithRefLinkType + /// + /// + public string GetRef() + { + if (this is IBomEntityWithRefLinkType) + { + Type thisType = this.GetType(); + PropertyInfo propInfo = thisType.GetProperty("Ref", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException("No \"string Ref\" attribute in class: " + thisType.Name); + } + return (string)propInfo.GetValue(this); + } + + return null; + } + + /// + /// Default implementation for derived classes which implement IBomEntityWithRefLinkType + /// + /// + public void SetRef(string s) + { + if (this is IBomEntityWithRefLinkType) + { + Type thisType = this.GetType(); + PropertyInfo propInfo = thisType.GetProperty("Ref", typeof(string)); + if (propInfo is null) + { + throw new BomEntityIncompatibleException("No \"string Ref\" attribute in class: " + thisType.Name); + } + propInfo.SetValue(this, s); + } + } + } + + /// + /// Helper class for Bom.GetBomRefsInContainers() et al discovery tracking. + /// + public class BomWalkResult + { + /// + /// The BomEntity (normally a whole Bom document) + /// which was walked and reported here. + /// + public BomEntity bomRoot { get; private set; } + + /// + /// Populated by GetBomRefsInContainers(), + /// keys are "container" entities and values + /// are lists of "contained" entities which + /// have a BomRef or equivalent property. + /// Exposed by GetBomRefsInContainers(). + /// + readonly private Dictionary> dictRefsInContainers = new Dictionary>(); + + /// + /// Populated by GetBomRefsInContainers(), + /// keys are "Ref" or equivalent string values + /// which link back to a "BomRef" hopefully + /// defined somewhere in the same Bom document + /// (but may be dangling, or sometimes co-opted + /// with external links to other Bom documents!), + /// and values are lists of entities which use + /// this same "ref" value. + /// Exposed by GetRefsInContainers(). + /// + readonly private Dictionary> dictBackrefs = new Dictionary>(); + + // Callers can enable performance monitoring + // (and printing in ToString() method) to help + // debug the data-walk overheads. Accounting + // does have a cost (~5% for a larger 20s run). + #pragma warning disable S3052 + public bool debugPerformance { get; set; } = false; + #pragma warning restore S3052 + + // Helpers for performance accounting - how hard + // was it to discover the information in this + // BomWalkResult object? + // TOTHINK: Expose these values directly, for + // very curious callers (for whom ToString() + // would not suffice)? + // * Use public getter/private setter? or... + // * Method to export a Dictionary of values, + // including a momentary reading of stopwatch? + private int sbeCountMethodEnter { get; set; } + private int sbeCountMethodQuickExit { get; set; } + private int sbeCountPropInfoEnter { get; set; } + private int sbeCountPropInfoQuickExit { get; set; } + private int sbeCountPropInfoQuickExit2 { get; set; } + private int sbeCountPropInfo { get; set; } + private int sbeCountPropInfo_EvalIsBomref { get; set; } + private int sbeCountPropInfo_EvalIsNotStringBomref { get; set; } + private int sbeCountPropInfo_EvalIsStringNotNamedBomref { get; set; } + private int sbeCountPropInfo_EvalIsStringNotNamedRef { get; set; } + private int sbeCountPropInfo_EvalXMLAttr { get; set; } + private int sbeCountPropInfo_EvalJSONAttr { get; set; } + private int sbeCountPropInfo_EvalIsRefLinkListString { get; set; } + private int sbeCountPropInfo_EvalList { get; set; } + private int sbeCountPropInfo_EvalListQuickExit { get; set; } + private int sbeCountPropInfo_EvalListWalk { get; set; } + private int sbeCountNewBomRefCheckDict { get; set; } + private int sbeCountNewBomRef { get; set; } + + // This one is initially null: the outermost walk loop + // makes a new instance, starts and stops this stopwatch + private Stopwatch stopWatchWalkTotal; + private Stopwatch stopWatchEvalAttr = new Stopwatch(); + private Stopwatch stopWatchNewBomref = new Stopwatch(); + private Stopwatch stopWatchNewBomrefCheck = new Stopwatch(); + private Stopwatch stopWatchNewBomrefNewListSpawn = new Stopwatch(); + private Stopwatch stopWatchNewBomrefNewListInDict = new Stopwatch(); + private Stopwatch stopWatchNewBomrefListAdd = new Stopwatch(); + private Stopwatch stopWatchNewRefLink = new Stopwatch(); + private Stopwatch stopWatchNewRefLinkListString = new Stopwatch(); + private Stopwatch stopWatchGetValue = new Stopwatch(); + + public void reset() + { + dictRefsInContainers.Clear(); + dictBackrefs.Clear(); + + sbeCountMethodEnter = 0; + sbeCountMethodQuickExit = 0; + sbeCountPropInfoEnter = 0; + sbeCountPropInfoQuickExit = 0; + sbeCountPropInfoQuickExit2 = 0; + sbeCountPropInfo = 0; + sbeCountPropInfo_EvalIsBomref = 0; + sbeCountPropInfo_EvalIsNotStringBomref = 0; + sbeCountPropInfo_EvalIsStringNotNamedBomref = 0; + sbeCountPropInfo_EvalIsStringNotNamedRef = 0; + sbeCountPropInfo_EvalXMLAttr = 0; + sbeCountPropInfo_EvalJSONAttr = 0; + sbeCountPropInfo_EvalIsRefLinkListString = 0; + sbeCountPropInfo_EvalList = 0; + sbeCountPropInfo_EvalListQuickExit = 0; + sbeCountPropInfo_EvalListWalk = 0; + sbeCountNewBomRefCheckDict = 0; + sbeCountNewBomRef = 0; + + bomRoot = null; + stopWatchWalkTotal = null; + stopWatchEvalAttr = new Stopwatch(); + stopWatchNewBomref = new Stopwatch(); + stopWatchNewBomrefCheck = new Stopwatch(); + stopWatchNewBomrefNewListSpawn = new Stopwatch(); + stopWatchNewBomrefNewListInDict = new Stopwatch(); + stopWatchNewBomrefListAdd = new Stopwatch(); + stopWatchNewRefLink = new Stopwatch(); + stopWatchNewRefLinkListString = new Stopwatch(); + stopWatchGetValue = new Stopwatch(); + } + + public void reset(BomEntity newRoot) + { + this.reset(); + this.bomRoot = newRoot; + } + + private static string StopWatchToString(Stopwatch stopwatch) + { + string elapsed = "N/A"; + if (stopwatch != null) + { + // Get the elapsed time as a TimeSpan value. + TimeSpan ts = stopwatch.Elapsed; + elapsed = String.Format("{0:00}:{1:00}:{2:00}.{3:00}", + ts.Hours, ts.Minutes, ts.Seconds, + ts.Milliseconds / 10); + } + return elapsed; + } + + public override string ToString() + { + return "BomWalkResult: " + (debugPerformance ? + $"Timing.WalkTotal={StopWatchToString(stopWatchWalkTotal)} " + + $"sbeCountMethodEnter={sbeCountMethodEnter} " + + $"sbeCountMethodQuickExit={sbeCountMethodQuickExit} " + + $"sbeCountPropInfoEnter={sbeCountPropInfoEnter} " + + $"sbeCountPropInfoQuickExit={sbeCountPropInfoQuickExit} " + + $"Timing.GetValue={StopWatchToString(stopWatchGetValue)} " + + $"sbeCountPropInfo_EvalIsBomref={sbeCountPropInfo_EvalIsBomref} " + + $"sbeCountPropInfo_EvalIsNotStringBomref={sbeCountPropInfo_EvalIsNotStringBomref} " + + $"sbeCountPropInfo_EvalIsStringNotNamedBomref={sbeCountPropInfo_EvalIsStringNotNamedBomref} " + + $"sbeCountPropInfo_EvalIsStringNotNamedRef={sbeCountPropInfo_EvalIsStringNotNamedRef} " + + $"Timing.EvalAttr={StopWatchToString(stopWatchEvalAttr)} " + + $"sbeCountPropInfo_EvalXMLAttr={sbeCountPropInfo_EvalXMLAttr} " + + $"sbeCountPropInfo_EvalJSONAttr={sbeCountPropInfo_EvalJSONAttr} " + + $"sbeCountPropInfo_EvalIsRefLinkListString={sbeCountPropInfo_EvalIsRefLinkListString} " + + $"Timing.NewBomRef={StopWatchToString(stopWatchNewBomref)} (" + + $"Timing.NewBomRefCheck={StopWatchToString(stopWatchNewBomrefCheck)} " + + $"Timing.NewBomRefNewListSpawn={StopWatchToString(stopWatchNewBomrefNewListSpawn)} " + + $"Timing.NewBomRefNewListInDict={StopWatchToString(stopWatchNewBomrefNewListInDict)} " + + $"Timing.NewBomRefListAdd={StopWatchToString(stopWatchNewBomrefListAdd)}) " + + $"sbeCountNewBomRefCheckDict={sbeCountNewBomRefCheckDict} " + + $"sbeCountNewBomRef={sbeCountNewBomRef} " + + $"Timing.NewRefLink={StopWatchToString(stopWatchNewRefLink)} " + + $"Timing.NewRefLinkListString={StopWatchToString(stopWatchNewRefLinkListString)} " + + $"sbeCountPropInfo_EvalList={sbeCountPropInfo_EvalList} " + + $"sbeCountPropInfoQuickExit2={sbeCountPropInfoQuickExit2} " + + $"sbeCountPropInfo_EvalListQuickExit={sbeCountPropInfo_EvalListQuickExit} " + + $"sbeCountPropInfo_EvalListWalk={sbeCountPropInfo_EvalListWalk} " + + $"sbeCountPropInfo={sbeCountPropInfo} " + : "" ) + + $"dictRefsInContainers.Count={dictRefsInContainers.Count} " + + $"dictBackrefs.Count={dictBackrefs.Count}"; + } + + /// + /// Helper for Bom.GetBomRefsInContainers(). + /// + /// A BomEntity instance currently being investigated + /// A BomEntity instance whose attribute + /// (or member of a List<> attribute) is currently being + /// investigated. May be null when starting iteration + /// from this.GetBomRefsInContainers() method. + /// + public void SerializeBomEntity_BomRefs(BomEntity obj, BomEntity container) + { + // With CycloneDX spec 1.4 or older it might be feasible to + // walk specific properties of the Bom instance to look into + // their contents by known class types. As seen by excerpt + // from the spec below, just to list the locations where a + // "bom-ref" value can be set to identify an entity or where + // such value can be used to refer back to that entity, such + // approach is nearly infeasible starting with CDX 1.5 -- so + // use of reflection below is a more sustainable choice. + + // TL:DR further details: + // + // Looking in schema definitions search for items that should + // be bom-refs (whether the attributes of certain entry types, + // or back-references from whoever uses them): + // * in "*.schema.json" search for "#/definitions/refType", or + // * in "*.xsd" search for "bom:refType" and its super-set for + // certain use-cases "bom:bomReferenceType" + // Since CDX spec 1.5 note there is also a "refLinkType" with + // same formal syntax as "refType" but different purpose -- + // to specify back-references (as separate from identifiers + // of new unique entries). Also do not confuse with bomLink, + // bomLinkDocumentType, and bomLinkElementType which refer to + // entities in OTHER Bom documents (or those Boms themselves). + // + // As of CDX spec 1.4+, a "bom-ref" attribute can be specified in: + // * (1.4, 1.5) component/"bom-ref" + // * (1.4, 1.5) service/"bom-ref" + // * (1.4, 1.5) vulnerability/"bom-ref" + // * (1.5) organizationalEntity/"bom-ref" + // * (1.5) organizationalContact/"bom-ref" + // * (1.5) license/"bom-ref" + // * (1.5) license/licenseChoice/...expression.../"bom-ref" + // * (1.5) componentEvidence/occurrences[]/"bom-ref" + // * (1.5) compositions/"bom-ref" + // * (1.5) annotations/"bom-ref" + // * (1.5) modelCard/"bom-ref" + // * (1.5) componentData/"bom-ref" + // * (1.5) formula/"bom-ref" + // * (1.5) workflow/"bom-ref" + // * (1.5) task/"bom-ref" + // * (1.5) workspace/"bom-ref" + // * (1.5) trigger/"bom-ref" + // and referred from: + // * dependency/"ref" => only "component" (1.4), or + // "component or service" (since 1.5) + // * dependency/"dependsOn[]" => only "component" (1.4), + // or "component or service" (since 1.5) + // * (1.4, 1.5) compositions/"assemblies[]" => "component or service" + // * (1.4, 1.5) compositions/"dependencies[]" => "component or service" + // * (1.5) compositions/"vulnerabilities[]" => "vulnerability" + // ** NOTE: As of this writing, Composition.cs file + // defines Assemblies[], Dependencies[] and + // Vulnerabilities[] as lists of strings, + // each treated as a "ref" in class instance + // (de-)serializations + // ** (1.5) Of these, Assemblies[] may be either + // refLinkType or bomLinkElementType + // * (1.4, 1.5) vulnerability/affects/items/"ref" => "component or service" + // ** May be either refLinkType or bomLinkElementType + // * (1.5) componentEvidence/identity/tools[] => any, see spec + // ** May be either refLinkType or bomLinkElementType + // ** NOTE: As of this writing, EvidenceTools.cs is + // defined as a list of strings, each treated as + // a "ref" in class instance (de-)serializations + // * (1.5) annotations/subjects[] => any + // ** May be either refLinkType or bomLinkElementType + // ** In C# stored as List and exposed as + // a dynamically built List - this one is + // not of interest to the walk + // * (1.5) modelCard/modelParameters/datasets[]/"ref" => + // "data component" (see "#/definitions/componentData") + // ** May be either refLinkType or bomLinkElementType + // * (1.5) resourceReferenceChoice/"ref" => any + // ** May be either refLinkType or bomLinkElementType + // ** Used as a generalized reference type, summarized below + // + // Notably, CDX 1.5 also introduces resourceReferenceChoice + // which generalizes internal or external references, used in: + // * (1.5) workflow/resourceReferences[] + // * (1.5) task/resourceReferences[] + // * (1.5) workspace/resourceReferences[] + // * (1.5) trigger/resourceReferences[] + // * (1.5) event/{source,target} + // * (1.5) {inputType,outputType}/{source,target,resource} + // The CDX 1.5 tasks, workflows etc. also can reference each other. + // + // In particular, "component" instances (e.g. per JSON + // "#/definitions/component" spec search) can be direct + // properties (or property arrays) in: + // * (1.4, 1.5) component/pedigree/{ancestors,descendants,variants} + // * (1.4, 1.5) component/components[] -- structural hierarchy (not dependency tree) + // * (1.4, 1.5) bom/components[] + // * (1.4, 1.5) bom/metadata/component -- 0 or 1 item about the Bom itself + // * (1.5) bom/metadata/tools/components[] -- SW and HW tools used to create the Bom + // * (1.5) vulnerability/tools/components[] -- SW and HW tools used to describe the vuln + // * (1.5) formula/components[] + // + // Note that there may be potentially any level of nesting of + // components in components, and compositions, among other things. + // + // And "service" instances (per JSON "#/definitions/service"): + // * (1.4, 1.5) service/services[] + // * (1.4, 1.5) bom/services[] + // * (1.5) bom/metadata/tools/services[] -- services as tools used to create the Bom + // * (1.5) vulnerability/tools/services[] -- services as tools used to describe the vuln + // * (1.5) formula/services[] + // + // The CDX spec 1.5 also introduces "annotation" which can refer to + // such bom-ref carriers as service, component, organizationalEntity, + // organizationalContact. + if (debugPerformance) + { + sbeCountMethodEnter++; + } + + if (obj is null) + { + if (debugPerformance) + { + sbeCountMethodQuickExit++; + } + return; + } + + Type objType = obj.GetType(); + + // Sanity-check: we do not recurse into non-BomEntity types. + // Hopefully the compiler or runtime would not have let other obj's in... + if (objType is null || (!(typeof(BomEntity).IsAssignableFrom(objType)))) + { + if (debugPerformance) + { + sbeCountMethodQuickExit++; + } + return; + } + + bool isTimeAccounter = (stopWatchWalkTotal is null); + if (isTimeAccounter && debugPerformance) + { + stopWatchWalkTotal = new Stopwatch(); + stopWatchWalkTotal.Start(); + } + + // Looking up (comparing) keys in dictRefsInContainers[] is prohibitively + // expensive (may have to do with serialization into a string to implement + // GetHashCode() method), so we minimize interactions with that codepath. + // General assumption that we only look at same container once, but the + // code should cope with more visits (possibly at a cost). + List containerList = null; + + // TODO: Prepare a similar cache with only a subset of + // properties of interest for bom-ref search, to avoid + // looking into known dead ends in a loop. + PropertyInfo[] objProperties = BomEntity.KnownEntityTypeProperties[objType]; + if (objProperties.Length < 1) + { + objProperties = objType.GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + } + foreach (PropertyInfo propInfo in objProperties) + { + if (debugPerformance) + { + sbeCountPropInfoEnter++; + } + + // We do not recurse into non-BomEntity types + if (propInfo is null) + { + // Is this expected? Maybe throw? + if (debugPerformance) + { + sbeCountPropInfoQuickExit++; + } + continue; + } + + Type propType = propInfo.PropertyType; + if (debugPerformance) + { + stopWatchGetValue.Start(); + } + if (propInfo.Name.StartsWith("NonNullable")) { + // It is a getter/setter-wrapped facade + // of a Nullable for some T - skip, + // we would inspect the raw item instead + // (factual nulls would cause an exception + // and require a try/catch overhead here). + // FIXME: Is there an attribute for this, + // to avoid a string comparison in a loop? + if (debugPerformance) + { + sbeCountPropInfoQuickExit++; + stopWatchGetValue.Stop(); + } + continue; + } + var propVal = propInfo.GetValue(obj, null); + if (debugPerformance) + { + stopWatchGetValue.Stop(); + } + + if (propVal is null) + { + if (debugPerformance) + { + sbeCountPropInfoQuickExit++; + } + continue; + } + + // If the type of current "obj" contains a "bom-ref", or + // has annotations like [JsonPropertyName("bom-ref")] and + // [XmlAttribute("bom-ref")], save it into the dictionary. + // + // TODO: Pedantically it would be better to either parse + // and consult corresponding CycloneDX spec, somehow, for + // properties which have needed schema-defined type (see + // detailed comments in GetBomRefsInContainers() method). + if (debugPerformance) + { + sbeCountPropInfo_EvalIsBomref++; + } + bool propIsBomRef = false; + bool propIsRefLink = false; + bool propIsRefLinkListString = false; + if (propType.GetTypeInfo().IsAssignableFrom(typeof(string))) + { + // NOTE: Current CycloneDX spec (1.5 and those before it) + // explicitly specify reference fields as a string type. + // Wondering if this would change in the future (more so + // with higher-level grouping types like "refLinkType" or + // "bomLink", or generic "link to somewhere" such as + // "anyOf refLinkType or bomLinkElementType") which are + // a frequent occurrence starting from CDX spec 1.5... + propIsBomRef = (propInfo.Name == "BomRef"); + if (!propIsBomRef) + { + if (debugPerformance) + { + sbeCountPropInfo_EvalIsStringNotNamedBomref++; + } + propIsRefLink = (propInfo.Name == "Ref"); + } + if (!propIsRefLink) + { + if (debugPerformance) + { + sbeCountPropInfo_EvalIsStringNotNamedRef++; + } + if (!propIsBomRef) + { + if (debugPerformance) + { + sbeCountPropInfo_EvalXMLAttr++; + stopWatchEvalAttr.Start(); + } + object[] attrs = propInfo.GetCustomAttributes(typeof(XmlAttribute), false); + if (attrs.Length > 0) + { + propIsBomRef = (Array.Find(attrs, x => ((XmlAttribute)x).Name == "bom-ref") != null); + } + if (debugPerformance) + { + stopWatchEvalAttr.Stop(); + } + } + if (!propIsBomRef) + { + if (debugPerformance) + { + sbeCountPropInfo_EvalJSONAttr++; + stopWatchEvalAttr.Start(); + } + object[] attrs = propInfo.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false); + if (attrs.Length > 0) + { + propIsBomRef = (Array.Find(attrs, x => ((JsonPropertyNameAttribute)x).Name == "bom-ref") != null); + } + if (debugPerformance) + { + stopWatchEvalAttr.Stop(); + } + } + } + } + else + { + if (debugPerformance) + { + sbeCountPropInfo_EvalIsNotStringBomref++; + } + + // Check for those few variables which are lists of strings + // with "ref"-like items. + // As noted above, "annotations/subjects[]" are not handled + // here as a list of strings, because that is a shim view. + if (propType.GetTypeInfo().IsAssignableFrom(typeof(List))) + { + if (debugPerformance) + { + sbeCountPropInfo_EvalIsRefLinkListString++; + } + + if (( + objType == typeof(Composition) && + (propInfo.Name == "Assemblies" + || propInfo.Name == "Dependencies" + || propInfo.Name == "Vulnerabilities") + ) || (objType == typeof(EvidenceIdentity) && + propInfo.Name == "Tools" + ) || objType == typeof(EvidenceTools) // Actually this should not hit, presumably, as its "obj" is not a BomEntity and the EvidenceIdentity contains this (list class) as a property + ) + { + propIsRefLinkListString = true; + } + } + } + + if (propIsBomRef) + { + // Save current object into tracking, and be done with this prop! + if (debugPerformance) + { + stopWatchNewBomref.Start(); + } + if (containerList is null) + { + if (debugPerformance) + { + sbeCountNewBomRefCheckDict++; + stopWatchNewBomrefCheck.Start(); + } + + #pragma warning disable S125 + // "proper" dict key lookup probably goes via hashes + // which go via serialization for BomEntity classes, + // and so walking a Bom with a hundred Components + // takes a second with "apparent" loop like: + // if (dictRefsInContainers.TryGetValue(container, out List list)) + // but takes miniscule fractions as it should, when + // we avoid hashing like this (and also maintain + // consistent references if original objects get + // modified - so serialization and hash changes; + // this should not happen in this loop, and the + // intention is to keep tabs on references to all + // original objects so we can rename what we need): + #pragma warning restore S125 + foreach (var (cont, list) in dictRefsInContainers) + { + if (Object.ReferenceEquals(container, cont)) + { + containerList = list; + break; + } + } + if (debugPerformance) + { + stopWatchNewBomrefCheck.Stop(); + } + + if (containerList is null) + { + if (debugPerformance) + { + stopWatchNewBomrefNewListSpawn.Start(); + } + containerList = new List(); + if (debugPerformance) + { + stopWatchNewBomrefNewListSpawn.Stop(); + stopWatchNewBomrefNewListInDict.Start(); + } + dictRefsInContainers[container] = containerList; + if (debugPerformance) + { + stopWatchNewBomrefNewListInDict.Stop(); + } + } + } + + if (debugPerformance) + { + sbeCountNewBomRef++; + stopWatchNewBomrefListAdd.Start(); + } + containerList.Add(obj); + if (debugPerformance) + { + stopWatchNewBomrefListAdd.Stop(); + stopWatchNewBomref.Stop(); + } + + // Done with this (string) property, look at next + continue; + } + + if (propIsRefLink) + { + // Save current object into "back-reference" tracking, + // and be done with this prop! + // Note: this approach covers only string "ref" properties, + // but not those few with a "List" - handled below. + // Note: It is currently somewhat up to the consumer + // of these results to guess (or find) which "obj" + // property is the reference (currently tends to be + // called "Ref", but...). For the greater purposes of + // entities' "bom-ref" renaming this could surely be + // optimized. + if (debugPerformance) + { + stopWatchNewRefLink.Start(); + } + + string sPropVal = (string)propVal; + // nullness ruled out above + if (sPropVal == "") + { + continue; + } + + if (!(dictBackrefs.TryGetValue(sPropVal, out List listBackrefs))) + { + listBackrefs = new List(); + dictBackrefs[sPropVal] = listBackrefs; + } + listBackrefs.Add(obj); + + if (debugPerformance) + { + stopWatchNewRefLink.Stop(); + } + + // Done with this (string) property, look at next + continue; + } + + if (propIsRefLinkListString) + { + // Save current object into "back-reference" tracking, + // and be done with this prop! + // Note: It is currently somewhat up to the consumer + // of these results to guess (or find) which "obj" + // property is the list with the reference (and which + // list item, by number). For the greater purposes of + // entities' "bom-ref" renaming this could surely be + // optimized. + if (debugPerformance) + { + stopWatchNewRefLinkListString.Start(); + } + + List lsPropVal = (List)propVal; + if (lsPropVal.Count > 0) + { + // Walk all items and list in backrefs pointing to this object + foreach (string sPropVal in lsPropVal) + { + if (sPropVal is null || sPropVal == "") + { + continue; + } + if (!(dictBackrefs.TryGetValue(sPropVal, out List listBackrefs))) + { + listBackrefs = new List(); + dictBackrefs[sPropVal] = listBackrefs; + } + listBackrefs.Add(obj); + } + } + + if (debugPerformance) + { + stopWatchNewRefLinkListString.Stop(); + } + + // Done with this (string) property, look at next + continue; + } + + // We do not recurse into non-BomEntity types + if (debugPerformance) + { + sbeCountPropInfo_EvalList++; + } + bool propIsListBomEntity = ( + (propType.GetTypeInfo().ImplementedInterfaces.Contains(typeof(System.Collections.IList))) + && (Array.Find(propType.GetTypeInfo().GenericTypeArguments, + x => typeof(BomEntity).GetTypeInfo().IsAssignableFrom(x.GetTypeInfo())) != null) + ); + + if (!( + propIsListBomEntity + || (typeof(BomEntity).GetTypeInfo().IsAssignableFrom(propType.GetTypeInfo())) + )) + { + // Not a BomEntity or (potentially) a List of those + if (debugPerformance) + { + sbeCountPropInfoQuickExit2++; + } + continue; + } + + if (propIsListBomEntity) + { + // Use cached info where available + PropertyInfo listPropCount = null; + MethodInfo listMethodGetItem = null; + MethodInfo listMethodAdd = null; + if (BomEntity.KnownEntityTypeLists.TryGetValue(propType, out BomEntityListReflection refInfo)) + { + listPropCount = refInfo.propCount; + listMethodGetItem = refInfo.methodGetItem; + listMethodAdd = refInfo.methodAdd; + } + else + { + // No cached info about BomEntityListReflection[{propType} + listPropCount = propType.GetProperty("Count"); + listMethodGetItem = propType.GetMethod("get_Item"); + listMethodAdd = propType.GetMethod("Add"); + } + + if (listMethodGetItem == null || listPropCount == null || listMethodAdd == null) + { + // Should not have happened, but... + if (debugPerformance) + { + sbeCountPropInfo_EvalListQuickExit++; + } + continue; + } + + int propValCount = (int)listPropCount.GetValue(propVal, null); + if (propValCount < 1) + { + // Empty list + if (debugPerformance) + { + sbeCountPropInfo_EvalListQuickExit++; + } + continue; + } + + if (debugPerformance) + { + sbeCountPropInfo_EvalListWalk++; + } + for (int o = 0; o < propValCount; o++) + { + var listVal = listMethodGetItem.Invoke(propVal, new object[] { o }); + if (listVal is null) + { + continue; + } + + if (!(listVal is BomEntity)) + { + break; + } + + SerializeBomEntity_BomRefs((BomEntity)listVal, obj); + } + + // End of list, or a break per above + continue; + } + + if (debugPerformance) + { + sbeCountPropInfo++; + } + SerializeBomEntity_BomRefs((BomEntity)propVal, obj); + } + + // nullness check seems bogus, but Codacy insists... + if (isTimeAccounter && debugPerformance && !(stopWatchWalkTotal is null)) + { + stopWatchWalkTotal.Stop(); + } + } + + /// + /// Provide a Dictionary whose keys are container BomEntities + /// and values are lists of one or more directly contained + /// entities with a BomRef attribute, e.g. the Bom itself and + /// the Components in it; or the Metadata and the Component + /// description in it; or certain Components or Tools with a + /// set of further "structural" components. + /// + /// The assumption per CycloneDX spec, not directly challenged + /// in this method, is that each such listed "contained entity" + /// (likely Component instances) has an unique BomRef value across + /// the whole single Bom document. Other Bom documents may however + /// have the same BomRef value (trivially "1", "2", ...) which + /// is attached to description of an unrelated entity. This can + /// impact such operations as a FlatMerge() of different Boms. + /// + /// See also: GetBomRefsWithContainer() with transposed returns. + /// + /// + public Dictionary> GetBomRefsInContainers() + { + return dictRefsInContainers; + } + + /// + /// Provide a Dictionary whose keys are "Ref" or equivalent + /// string values which link back to a "BomRef" hopefully + /// defined somewhere in the same Bom document (but may be + /// dangling, or sometimes co-opted with external links to + /// other Bom documents!), and whose values are lists of + /// BomEntities which use this same "ref" value. + /// + /// See also: GetBomRefsInContainers() with similar info + /// about keys which are BomEntity "containers" and values + /// are lists of BomEntity with a BomRef in those containers, + /// and GetBomRefsWithContainer() with transposed returns. + /// + /// + public Dictionary> GetRefsInContainers() + { + return dictBackrefs; + } + + /// + /// Provide a Dictionary whose keys are "contained" entities + /// with a BomRef attribute and values are their direct + /// container BomEntities, e.g. each Bom.Components[] list + /// entry referring the Bom itself; or the Metadata.Component + /// entry referring the Metadata; or further "structural" + /// components in certain Component or Tool entities. + /// + /// The assumption per CycloneDX spec, not directly challenged + /// in this method, is that each such listed "contained entity" + /// (likely Component instances) has an unique BomRef value across + /// the whole single Bom document. Other Bom documents may however + /// have the same BomRef value (trivially "1", "2", ...) which + /// is attached to description of an unrelated entity. This can + /// impact such operations as a FlatMerge() of different Boms. + /// + /// See also: GetBomRefsInContainers() with transposed returns. + /// + /// + public Dictionary GetBomRefsWithContainer() + { + Dictionary dictWithC = new Dictionary(); + + foreach (var (container, listItems) in dictRefsInContainers) + { + if (listItems is null || container is null || listItems.Count < 1) { + continue; + } + + foreach (var item in listItems) { + dictWithC[item] = container; + } + } + + return dictWithC; + } + } +} diff --git a/src/CycloneDX.Core/Models/Callstack.cs b/src/CycloneDX.Core/Models/Callstack.cs index 522d68db..da26c4f1 100644 --- a/src/CycloneDX.Core/Models/Callstack.cs +++ b/src/CycloneDX.Core/Models/Callstack.cs @@ -28,11 +28,11 @@ namespace CycloneDX.Models { [XmlType("callstack")] [ProtoContract] - public class Callstack + public class Callstack : BomEntity { [XmlType("frame")] [ProtoContract] - public class Frame + public class Frame : BomEntity { [XmlElement("package")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Command.cs b/src/CycloneDX.Core/Models/Command.cs index f0bfc1ff..4b319a88 100644 --- a/src/CycloneDX.Core/Models/Command.cs +++ b/src/CycloneDX.Core/Models/Command.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("command")] [ProtoContract] - public class Command + public class Command : BomEntity { [XmlElement("executed")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Commit.cs b/src/CycloneDX.Core/Models/Commit.cs index bd0ea2d1..60efa995 100644 --- a/src/CycloneDX.Core/Models/Commit.cs +++ b/src/CycloneDX.Core/Models/Commit.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Commit + public class Commit : BomEntity { [XmlElement("uid")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Component.cs b/src/CycloneDX.Core/Models/Component.cs index 76d8e9bf..5d679553 100644 --- a/src/CycloneDX.Core/Models/Component.cs +++ b/src/CycloneDX.Core/Models/Component.cs @@ -18,6 +18,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -27,7 +29,7 @@ namespace CycloneDX.Models [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] [XmlType("component")] [ProtoContract] - public class Component: IEquatable + public class Component: BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum Classification @@ -124,6 +126,10 @@ public ComponentScope NonNullableScope { get { + if (Scope == null) + { + return ComponentScope.Null; + } return Scope.Value; } set @@ -215,14 +221,694 @@ public bool NonNullableModified [ProtoMember(26)] public Data Data { get; set; } - public bool Equals(Component obj) + public bool Equivalent(Component obj) { - return Json.Serializer.Serialize(this) == Json.Serializer.Serialize(obj); + // "this" is not null ever, so the counterpart also should not be :) + if (obj is null) + { + return false; + } + + // By spec, as of CDX 1.4, "type" and "name" are the two required + // properties. Commonly, a "version" or "purl" makes sense to be + // sure about (not-)sameness of two components. And historically, + // for many computer-generated BOMs the "bom-ref" is representative. + // Notably, we care about BomRef because we might refer to this + // entity from others in the same document (e.g. Dependencies[]), + // and (inter-)BOM references are done by this value. + // NOTE: If two otherwise identical components are refferred to + // by different BomRef values - so be it, Bom duplicates remain. + if (this.Type == obj.Type // No nullness check here, or we get: error CS0037: Cannot convert null to 'Component.Classification' because it is a non-nullable value type + && !(this.Name is null) && !(obj.Name is null) && this.Name == obj.Name + && (this.Version is null || obj.Version is null || this.Version == obj.Version) + && (this.Purl is null || obj.Purl is null || this.Purl == obj.Purl) + && (this.BomRef is null || obj.BomRef is null || this.BomRef == obj.BomRef) + ) + { + // These two seem equivalent enough to go on with the more + // expensive logic such as MergeWith() which may ultimately + // reject the merge request - based on some unresolvable + // incompatibility in some other data fields or lists: + return true; + } + + // Could not prove equivalence, err on the safe side: + return false; } - - public override int GetHashCode() + + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) { - return Json.Serializer.Serialize(this).GetHashCode(); + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.BomRef, o?.Type, o?.Group, o?.Name, o?.Version), + null); + } + + /// + /// See BomEntity.MergeWith() + /// + public bool MergeWith(Component obj, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + if (!int.TryParse(System.Environment.GetEnvironmentVariable("CYCLONEDX_DEBUG_MERGE"), out int iDebugLevel) || iDebugLevel < 0) + { + iDebugLevel = 0; + } + + try + { + // Basic checks for null, type compatibility, + // equality and non-equivalence; throws for + // the hard stuff to implement in the catch: + bool resBase = base.MergeWith(obj, listMergeHelperStrategy); + if (iDebugLevel >= 1) + { + if (resBase) + { + Console.WriteLine($"Component.MergeWith(): SKIP: contents are identical, nothing to do"); + } + else + { + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SKIP: items do not seem related"); + } + } + } + return resBase; + } + catch (BomEntityConflictException) + { + // No-op to fall through below with less indentation + } + + // Custom logic to squash together two equivalent entries - + // with same BomRef value but something differing elsewhere + // TODO: Much of this seems reusable - if other classes get + // a need for some fully-fledged MergeWith, consider breaking + // this code into helper methods and patterns, so that only + // specific property hits would be customized and the default + // scaffolding shared. + if ( + (this.BomRef != null && this.BomRef.Equals(obj.BomRef)) || + (this.Group == obj.Group && this.Name == obj.Name && this.Version == obj.Version) + ) { + // Objects seem equivalent according to critical arguments => + // merge the attribute values with help of reflection: + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): items seem related - investigate properties: {this.BomRef} / {this.Group} : {this.Name} : {this.Version}"); + } + PropertyInfo[] properties = BomEntity.KnownEntityTypeProperties[this.GetType()]; + if (iDebugLevel >= 2) + { + Console.WriteLine($"Component.MergeWith(): items seem related - investigate properties: num {properties.Length}: {properties.ToString()}"); + } + + // Use a temporary clone instead of mangling "this" object right away. + // Note: serialization seems to skip over "nonnullable" values in some cases. + Component tmp = new Component(); + /* This copier fails due to copy of "non-null" fields which may be null: + * tmp = JsonSerializer.Deserialize(CycloneDX.Json.Serializer.Serialize(this)) + */ + foreach (PropertyInfo property in properties) + { + try { + // Avoid spurious "modified=false" in merged JSON + // Also skip helpers, care about real values + if ((property.Name == "Modified" || property.Name == "NonNullableModified") && !(this.Modified.HasValue)) { + // Can not set R/O prop: ### tmp.Modified.HasValue = false + continue; + } + if ((property.Name == "Scope" || property.Name == "NonNullableScope") && !(this.Scope.HasValue)) { + // Can not set R/O prop: ### tmp.Scope.HasValue = false + continue; + } + property.SetValue(tmp, property.GetValue(this, null)); + } catch (System.Exception) { + // no-op + } + } + bool mergedOk = true; + + foreach (PropertyInfo property in properties) + { + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): <{property.PropertyType}>'{property.Name}'"); + } + switch (property.PropertyType) + { + case Type _ when property.PropertyType == typeof(Nullable): + break; + + case Type _ when property.PropertyType == typeof(ComponentScope): + { + // NOTE: Intentionally not matching 'Scope' helper + // Not nullable! Quickly keep un-set if applicable. + if (!(obj.Scope.HasValue) && !(tmp.Scope.HasValue)) + { + continue; + } + + ComponentScope tmpItem; + try + { + tmpItem = (ComponentScope)property.GetValue(tmp, null); + } + catch (System.InvalidOperationException) + { + // Unspecified => required per CycloneDX spec v1.4?.. + // Currently handled below like that, so (enum) Null value here. + tmpItem = ComponentScope.Null; + } + catch (System.Reflection.TargetInvocationException) + { + tmpItem = ComponentScope.Null; + } + + ComponentScope objItem; + try + { + objItem = (ComponentScope)property.GetValue(obj, null); + } + catch (System.InvalidOperationException) + { + objItem = ComponentScope.Null; + } + catch (System.Reflection.TargetInvocationException) + { + objItem = ComponentScope.Null; + } + + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SCOPE: '{tmpItem}' and '{objItem}'"); + } + + // Since CycloneDX spec v1.0 up to at least v1.4, + // an absent value "SHOULD" be treated as "required" + if (tmpItem != ComponentScope.Excluded && objItem != ComponentScope.Excluded) + { + // BOTH are not specified + if (tmpItem == ComponentScope.Null && objItem == ComponentScope.Null) + { + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SCOPE: keep unspecified explicitly"); + } + continue; + } + + if (tmpItem == ComponentScope.Optional && objItem == ComponentScope.Optional) + { + property.SetValue(tmp, ComponentScope.Optional); + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SCOPE: keep 'Optional'"); + } + continue; + } + + // Any one (or both) are Required, or Null meaning required: + // keep absent=>required; upgrade optional objItem + property.SetValue(tmp, ComponentScope.Required); + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SCOPE: set 'Required'"); + } + continue; + } + + // NOTE: "excluded" is only defined since CycloneDX spec v1.1 => + // you should not see it read from v1.0 documents. + // TOTHINK: Theoretically: what if we are asked to output a v1.0 + // document after merge of newer documents? Emitter should care... + if ( + (tmpItem == ComponentScope.Excluded && objItem == ComponentScope.Optional) || + (objItem == ComponentScope.Excluded && tmpItem == ComponentScope.Optional) + ) { + // downgrade optional objItem to excluded + property.SetValue(tmp, ComponentScope.Excluded); + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): SCOPE: set 'Excluded'"); + } + continue; + } + + // TODO: Having two same bom-refs is a syntax validation error... + // Here throw some exception or trigger creation of new object with a + // new bom-ref - and a new identification in the original document to + // avoid conflicts; be sure then to check for other entries that have + // everything same except bom-ref (match the expected new pattern)?.. + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): WARNING: can not merge two bom-refs with scope excluded and required"); + } + mergedOk = false; + } + break; + + case Type _ when (property.Name == "NonNullableModified"): + { + // Not nullable! Keep un-set if applicable. + if (!obj.Modified.HasValue) + { + continue; + } + + bool tmpItem = (bool)property.GetValue(tmp, null); + bool objItem = (bool)property.GetValue(obj, null); + + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): MODIFIED BOOL: '{tmpItem}' and '{objItem}'"); + } + + if (objItem) + { + property.SetValue(tmp, true); + } + } + break; + + case Type _ when (property.PropertyType == typeof(List) || property.PropertyType.ToString().StartsWith("System.Collections.Generic.List")): + { + // https://www.experts-exchange.com/questions/22600200/Traverse-generic-List-using-C-Reflection.html + var propValTmp = property.GetValue(tmp); + var propValObj = property.GetValue(obj); + if (propValTmp == null && propValObj == null) + { + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): LIST?: got in tmp and in obj"); + } + continue; + } + + var LType = (propValTmp == null ? propValObj.GetType() : propValTmp.GetType()); + // Use cached info where available + PropertyInfo propCount = null; + MethodInfo methodGetItem = null; + MethodInfo methodAdd = null; + if (BomEntity.KnownEntityTypeLists.TryGetValue(LType, out BomEntityListReflection refInfo)) + { + propCount = refInfo.propCount; + methodGetItem = refInfo.methodGetItem; + methodAdd = refInfo.methodAdd; + } + else + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): No cached info about BomEntityListReflection[{LType}]"); + } + propCount = LType.GetProperty("Count"); + methodGetItem = LType.GetMethod("get_Item"); + methodAdd = LType.GetMethod("Add"); + } + + if (methodGetItem == null || propCount == null || methodAdd == null) + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): WARNING: is this really a LIST - it lacks a get_Item() or Add() method, or a Count property"); + } + mergedOk = false; + continue; + } + + int propValTmpCount = (propValTmp == null ? -1 : (int)propCount.GetValue(propValTmp, null)); + int propValObjCount = (propValObj == null ? -1 : (int)propCount.GetValue(propValObj, null)); + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): LIST?: got {propValTmp}=>{propValTmpCount} in tmp and {propValObj}=>{propValObjCount} in obj"); + } + + if (propValObj == null || propValObjCount < 1) + { + continue; + } + + if (propValTmp == null || propValTmpCount < 1) + { + property.SetValue(tmp, propValObj); + continue; + } + + var TType = methodGetItem.Invoke(propValObj, new object[] { 0 }).GetType(); + if (!KnownTypeMergeWith.TryGetValue(TType, out var methodMergeWith)) + { + // No need to re-query now that we have BomEntity descendance: + // e.g. methodMergeWith = TType.GetMethod("MergeWith", 0, new [] { TType, typeof(BomEntityListMergeHelperStrategy) }) + methodMergeWith = null; + } + + if (!KnownTypeEquals.TryGetValue(TType, out var methodEquals)) + { + if (KnownDefaultEquals.TryGetValue(TType, out var methodEquals2)) + { + methodEquals = methodEquals2; + } + else + { + if (KnownOtherTypeEquals.TryGetValue(TType, out var methodEquals3)) + { + methodEquals = methodEquals3; + } + else + { + methodEquals = TType.GetMethod("Equals", 0, new [] { TType }); + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): had to look for {TType}.Equals()... found? {methodEquals != null}"); + } + } + } + } + + for (int o = 0; o < propValObjCount; o++) + { + var objItem = methodGetItem.Invoke(propValObj, new object[] { o }); + if (objItem is null) + { + continue; + } + + bool listHit = false; + for (int t = 0; t < propValTmpCount && !listHit; t++) + { + var tmpItem = methodGetItem.Invoke(propValTmp, new object[] { t }); + if (tmpItem != null) + { + // EQ CHECK + bool propsSeemEqual = false; + bool propsSeemEqualLearned = false; + + try + { + if (methodEquals != null) + { + if (iDebugLevel >= 6) + { + Console.WriteLine($"Component.MergeWith(): try methodEquals()"); + } + propsSeemEqual = (bool)methodEquals.Invoke(tmpItem, new [] {objItem}); + propsSeemEqualLearned = true; + } + } + catch (System.Exception exc) + { + // no-op + if (iDebugLevel >= 6) + { + Console.WriteLine($"Component.MergeWith(): can not check Equals() {propValTmp.ToString()} and {propValObj.ToString()}: {exc.ToString()}"); + } + } + + if (propsSeemEqual || !propsSeemEqualLearned) + { + // Got an equivalently-looking item on both sides! + // If there is no mergeWith() in its class, consider + // the two entries just equal (no-op to merge them). + listHit = true; + if (methodMergeWith != null) + { + try + { + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): Call futher {TType.ToString()}.mergeWith() for '{property.Name}': merge of {tmpItem?.ToString()} and {objItem?.ToString()}"); + } + if (!((bool)methodMergeWith.Invoke(tmpItem, new [] {objItem, listMergeHelperStrategy}))) + { + mergedOk = false; + } + } + catch (System.Exception exc) + { + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): SKIP MERGE: can not {this.GetType().ToString()}.mergeWith() '{property.Name}' of {tmpItem?.ToString()} and {objItem?.ToString()}: {exc.ToString()}"); + } + mergedOk = false; + } + } // else: no method, just trust equality - avoid "Add" to merge below + else + { + if (iDebugLevel >= 7) + { + Console.WriteLine($"Component.MergeWith(): SKIP MERGE: can not {this.GetType().ToString()}.mergeWith() '{property.Name}' of {tmpItem?.ToString()} and {objItem?.ToString()}: no such method: will add to list"); + } + } + } // else: tmpitem considered not equal, should be added + } + } + + if (!listHit) + { + methodAdd.Invoke(propValTmp, new [] {objItem}); + propValTmpCount = (int)propCount.GetValue(propValTmp, null); + } + } + } + break; + + // Default handling for enums, if not customized above + case Type _ when (property.PropertyType.IsEnum): + { + // Not nullable! + var propValTmp = property.GetValue(tmp, null); + var propValObj = property.GetValue(obj, null); + // For some reason, reflected enums do not like getting compared + if (propValTmp == propValObj || propValTmp.Equals(propValObj) || ((Enum)propValTmp).CompareTo((Enum)propValObj) == 0 || propValTmp.ToString().Equals(propValObj.ToString())) + { + continue; + } + + mergedOk = false; + } + break; + + default: + { + if ( + property.PropertyType.Name.StartsWith("Nullable") || + property.PropertyType.ToString().StartsWith("System.Nullable") + ) + { + // e.g. 'Scope' helper + // followed by '{ComponentScope NonNullableScope}' + // which we specially handle above + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): SKIP NullableAttribute"); + } + continue; + } + + if (iDebugLevel >= 4) + { + Console.WriteLine($"Component.MergeWith(): DEFAULT TYPES"); + } + var propValTmp = property.GetValue(tmp, null); + var propValObj = property.GetValue(obj, null); + if (propValObj == null) + { + continue; + } + + if (propValTmp == null) + { + property.SetValue(tmp, propValObj); + continue; + } + + var TType = propValTmp.GetType(); + if (!KnownTypeEquals.TryGetValue(TType, out var methodEquals)) + { + if (KnownDefaultEquals.TryGetValue(TType, out var methodEquals2)) + { + methodEquals = methodEquals2; + } + else + { + if (KnownOtherTypeEquals.TryGetValue(TType, out var methodEquals3)) + { + methodEquals = methodEquals3; + } + else + { + methodEquals = TType.GetMethod("Equals", 0, new [] { TType }); + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): had to look for {TType}.Equals()... found? {methodEquals != null}"); + } + } + } + } + + // Track the result of comparison, and if we did find and + // run a method for comparison (the result was "learned"): + bool propsSeemEqual = false; + bool propsSeemEqualLearned = false; + + try + { + if (methodEquals != null) + { + if (iDebugLevel >= 6) + { + Console.WriteLine($"Component.MergeWith(): try methodEquals()"); + } + propsSeemEqual = (bool)methodEquals.Invoke(propValTmp, new [] {propValObj}); + propsSeemEqualLearned = true; + } + } + catch (System.Exception exc) + { + // no-op + if (iDebugLevel >= 6) + { + Console.WriteLine($"Component.MergeWith(): can not check Equals() {propValTmp.ToString()} and {propValObj.ToString()}: {exc.ToString()}"); + } + } + + try + { + if (!propsSeemEqualLearned) + { + // Fall back to generic equality check which may be useless + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): MIGHT SKIP MERGE: items say they are equal"); + } + propsSeemEqual = propValTmp.Equals(propValObj); + propsSeemEqualLearned = true; + } + } + catch (System.Exception) + { + // no-op + } + + try + { + if (!propsSeemEqualLearned) + { + // Fall back to generic equality check which may be useless + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): SKIP MERGE: items say they are equal"); + } + propsSeemEqual = (propValTmp == propValObj); + propsSeemEqualLearned = true; + } + } + catch (System.Exception) + { + // no-op + } + + if (!propsSeemEqual) + { + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): items say they are not equal"); + } + } + + if (!KnownTypeMergeWith.TryGetValue(TType, out var methodMergeWith)) + { + // No need to re-query now that we have BomEntity descendance: + // e.g. methodMergeWith = TType.GetMethod("MergeWith", 0, new [] { TType, typeof(BomEntityListMergeHelperStrategy) }) + methodMergeWith = null; + } + + if (methodMergeWith != null) + { + try + { + if (!((bool)methodMergeWith.Invoke(propValTmp, new [] {propValObj, listMergeHelperStrategy}))) + { + mergedOk = false; + } + } + catch (System.Exception exc) + { + // That property's class lacks a mergeWith(), gotta trust the equality: + if (propsSeemEqual) + { + continue; + } + if (iDebugLevel >= 5) + { + Console.WriteLine($"Component.MergeWith(): FAILED MERGE: can not {this.GetType().ToString()}.mergeWith() '{property.Name}' of {tmp.ToString()} and {obj.ToString()}: {exc.ToString()}"); + } + mergedOk = false; + } + } + else + { + // That property's class lacks a mergeWith(), gotta trust the equality: + if (propsSeemEqual) + { + continue; + } + if (iDebugLevel >= 7) + { + Console.WriteLine($"Component.MergeWith(): SKIP MERGE: can not {this.GetType().ToString()}.mergeWith() '{property.Name}' of {tmp.ToString()} and {obj.ToString()}: no such method"); + } + mergedOk = false; + } + } + break; + } + } + + if (mergedOk) { + // No failures, only now update the current object: + foreach (PropertyInfo property in properties) + { + // Avoid spurious "modified=false" in merged JSON + // Also skip helpers, care about real values + if ((property.Name == "Modified" || property.Name == "NonNullableModified") && !(tmp.Modified.HasValue)) { + // Can not set R/O prop: ### this.Modified.HasValue = false + continue; + } + if ((property.Name == "Scope" || property.Name == "NonNullableScope") && !(tmp.Scope.HasValue)) { + // Can not set R/O prop: ### this.Scope.HasValue = false + continue; + } + property.SetValue(this, property.GetValue(tmp, null)); + } + } + + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): result {mergedOk} for: {this.BomRef} / {this.Group} : {this.Name} : {this.Version}"); + } + return mergedOk; + } + else + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"Component.MergeWith(): SKIP: items do not seem related upon second look"); + } + } + + // Merge was not applicable or otherwise did not succeed + return false; } } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/Composition.cs b/src/CycloneDX.Core/Models/Composition.cs index 8b3140d4..5170db14 100644 --- a/src/CycloneDX.Core/Models/Composition.cs +++ b/src/CycloneDX.Core/Models/Composition.cs @@ -15,8 +15,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; using System.Xml; using System.Xml.Serialization; using System.Text.Json.Serialization; @@ -25,7 +28,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Composition : IXmlSerializable + public class Composition : BomEntity, IXmlSerializable, IBomEntityWithRefType_String_BomRef, IBomEntityWithRefLinkType_StringList { [ProtoContract] public enum AggregateType @@ -194,5 +197,56 @@ public void WriteXml(System.Xml.XmlWriter writer) { writer.WriteEndElement(); } } + + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.Aggregate, o?.Assemblies, o?.Dependencies), + null); + } + + private static readonly ImmutableDictionary> RefLinkConstraints_List_v1_3 = + new Dictionary> + { + { typeof(Composition).GetProperty("Assemblies", typeof(List)), RefLinkConstraints_ComponentOrService }, + { typeof(Composition).GetProperty("Dependencies", typeof(List)), RefLinkConstraints_ComponentOrService } + }.ToImmutableDictionary(); + + private static readonly ImmutableDictionary> RefLinkConstraints_List_v1_5 = + new Dictionary> + { + { typeof(Composition).GetProperty("Assemblies", typeof(List)), RefLinkConstraints_ComponentOrService }, + { typeof(Composition).GetProperty("Dependencies", typeof(List)), RefLinkConstraints_ComponentOrService }, + { typeof(Composition).GetProperty("Vulnerabilities", typeof(List)), RefLinkConstraints_Vulnerability } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + switch (specificationVersion) + { + case v1_0: + case v1_1: + case v1_2: + return null; + + case v1_3: + case v1_4: + return RefLinkConstraints_List_v1_3; + + case v1_5: + default: + return RefLinkConstraints_List_v1_5; + } + } } -} \ No newline at end of file +} diff --git a/src/CycloneDX.Core/Models/Data.cs b/src/CycloneDX.Core/Models/Data.cs index 7741a68a..5d800533 100644 --- a/src/CycloneDX.Core/Models/Data.cs +++ b/src/CycloneDX.Core/Models/Data.cs @@ -25,7 +25,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Data + public class Data : BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum DataType @@ -43,7 +43,7 @@ public enum DataType } [ProtoContract] - public class DataContents + public class DataContents : BomEntity { [XmlElement("attachment")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/DataClassification.cs b/src/CycloneDX.Core/Models/DataClassification.cs index 1defc8a2..128c23b4 100644 --- a/src/CycloneDX.Core/Models/DataClassification.cs +++ b/src/CycloneDX.Core/Models/DataClassification.cs @@ -25,7 +25,7 @@ namespace CycloneDX.Models // this is the version that was prior to v1.5 [XmlType("classification")] [ProtoContract] - public class DataClassification + public class DataClassification : BomEntity { [XmlAttribute("flow")] [ProtoMember(1, IsRequired=true)] diff --git a/src/CycloneDX.Core/Models/DataFlow.cs b/src/CycloneDX.Core/Models/DataFlow.cs index 099d6d1b..24803811 100644 --- a/src/CycloneDX.Core/Models/DataFlow.cs +++ b/src/CycloneDX.Core/Models/DataFlow.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [XmlType("dataflow")] [ProtoContract] - public class DataFlow + public class DataFlow : BomEntity { [XmlIgnore] [JsonPropertyName("flow")] diff --git a/src/CycloneDX.Core/Models/DataGovernance.cs b/src/CycloneDX.Core/Models/DataGovernance.cs index 0b8b939e..307af00d 100644 --- a/src/CycloneDX.Core/Models/DataGovernance.cs +++ b/src/CycloneDX.Core/Models/DataGovernance.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("data-governance")] [ProtoContract] - public class DataGovernance + public class DataGovernance : BomEntity { [XmlArray("custodians")] [XmlArrayItem("custodian")] diff --git a/src/CycloneDX.Core/Models/DataflowSourceDestination.cs b/src/CycloneDX.Core/Models/DataflowSourceDestination.cs index e2b4157b..96f4b569 100644 --- a/src/CycloneDX.Core/Models/DataflowSourceDestination.cs +++ b/src/CycloneDX.Core/Models/DataflowSourceDestination.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class DataflowSourceDestination + public class DataflowSourceDestination : BomEntity { [XmlElement("url")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/DatasetChoice.cs b/src/CycloneDX.Core/Models/DatasetChoice.cs index c50d9684..32e8d95a 100644 --- a/src/CycloneDX.Core/Models/DatasetChoice.cs +++ b/src/CycloneDX.Core/Models/DatasetChoice.cs @@ -15,13 +15,18 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; using System.Xml.Serialization; using ProtoBuf; namespace CycloneDX.Models { [ProtoContract] - public class DatasetChoice + public class DatasetChoice : BomEntity, IBomEntityWithRefLinkType_String_Ref { [XmlElement("dataset")] [ProtoMember(1)] @@ -30,5 +35,21 @@ public class DatasetChoice [XmlElement("ref")] [ProtoMember(2)] public string Ref { get; set; } + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_ModelDataset = + new Dictionary> + { + { typeof(DatasetChoice).GetProperty("Ref", typeof(string)), RefLinkConstraints_ModelDataset } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + // TODO: switch/case for CDX spec newer than 1.5 where this type got introduced + if (specificationVersion == v1_5) + { + return RefLinkConstraints_StringRef_ModelDataset; + } + return null; + } } } diff --git a/src/CycloneDX.Core/Models/Dependency.cs b/src/CycloneDX.Core/Models/Dependency.cs index f2bd35c0..a1b9fb64 100644 --- a/src/CycloneDX.Core/Models/Dependency.cs +++ b/src/CycloneDX.Core/Models/Dependency.cs @@ -15,9 +15,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Collections.Immutable; +using System.Reflection; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -26,7 +29,7 @@ namespace CycloneDX.Models { [XmlType("dependency")] [ProtoContract] - public class Dependency: IEquatable + public class Dependency : BomEntity, IBomEntityWithRefLinkType_String_Ref { [XmlAttribute("ref")] [ProtoMember(1)] @@ -36,14 +39,60 @@ public class Dependency: IEquatable [ProtoMember(2)] public List Dependencies { get; set; } - public bool Equals(Dependency obj) + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) { - return CycloneDX.Json.Serializer.Serialize(this) == CycloneDX.Json.Serializer.Serialize(obj); + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.Ref), + null); } - - public override int GetHashCode() + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_Component = + new Dictionary> + { + { typeof(Dependency).GetProperty("Ref", typeof(string)), RefLinkConstraints_Component } + }.ToImmutableDictionary(); + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_ComponentOrService = + new Dictionary> { - return CycloneDX.Json.Serializer.Serialize(this).GetHashCode(); + { typeof(Dependency).GetProperty("Ref", typeof(string)), RefLinkConstraints_ComponentOrService } + }.ToImmutableDictionary(); + + /// + /// See IBomEntityWithRefLinkType.GetRefLinkConstraints(). + /// + /// + /// + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + switch (specificationVersion) + { + case v1_0: + case v1_1: + return null; + + case v1_2: + case v1_3: + case v1_4: + // NOTE: XML and JSON schema descriptions differ: + // * in JSON, specs v1.2, 1.3 and 1.4 dealt with "components" + // * in XML since 1.2, and in JSON since 1.5, with "components or services" + //TOTHINK//return RefLinkConstraints_StringRef_Component?.. + + case v1_5: + default: + return RefLinkConstraints_StringRef_ComponentOrService; + } } } -} \ No newline at end of file +} diff --git a/src/CycloneDX.Core/Models/Diff.cs b/src/CycloneDX.Core/Models/Diff.cs index 3eda6e66..b2b5ddf4 100644 --- a/src/CycloneDX.Core/Models/Diff.cs +++ b/src/CycloneDX.Core/Models/Diff.cs @@ -21,11 +21,12 @@ namespace CycloneDX.Models { [ProtoContract] - public class Diff + public class Diff : BomEntity { [XmlElement("text")] [ProtoMember(1)] public AttachedText Text { get; set; } + [XmlElement("url")] [ProtoMember(2)] public string Url { get; set; } diff --git a/src/CycloneDX.Core/Models/EnvironmentVarChoice.cs b/src/CycloneDX.Core/Models/EnvironmentVarChoice.cs index 733b6ed0..5d8a8066 100644 --- a/src/CycloneDX.Core/Models/EnvironmentVarChoice.cs +++ b/src/CycloneDX.Core/Models/EnvironmentVarChoice.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class EnvironmentVarChoice + public class EnvironmentVarChoice : BomEntity { [ProtoMember(1)] public Property Property { get; set; } diff --git a/src/CycloneDX.Core/Models/Event.cs b/src/CycloneDX.Core/Models/Event.cs index ae9d4196..86286c01 100644 --- a/src/CycloneDX.Core/Models/Event.cs +++ b/src/CycloneDX.Core/Models/Event.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("event")] [ProtoContract] - public class Event + public class Event : BomEntity { [XmlElement("uid")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Evidence.cs b/src/CycloneDX.Core/Models/Evidence.cs index df0a092d..895e278a 100644 --- a/src/CycloneDX.Core/Models/Evidence.cs +++ b/src/CycloneDX.Core/Models/Evidence.cs @@ -28,7 +28,7 @@ namespace CycloneDX.Models { [XmlType("evidence")] [ProtoContract] - public class Evidence + public class Evidence : BomEntity { [XmlElement("licenses")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/EvidenceCopyright.cs b/src/CycloneDX.Core/Models/EvidenceCopyright.cs index 03161b88..d045c22b 100644 --- a/src/CycloneDX.Core/Models/EvidenceCopyright.cs +++ b/src/CycloneDX.Core/Models/EvidenceCopyright.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class EvidenceCopyright + public class EvidenceCopyright : BomEntity { [XmlText] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/EvidenceIdentity.cs b/src/CycloneDX.Core/Models/EvidenceIdentity.cs index a9c3c18a..32529e32 100644 --- a/src/CycloneDX.Core/Models/EvidenceIdentity.cs +++ b/src/CycloneDX.Core/Models/EvidenceIdentity.cs @@ -15,10 +15,13 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; +using System.Reflection; using System.Text.Json.Serialization; using System.Xml; using System.Xml.Serialization; @@ -28,7 +31,7 @@ namespace CycloneDX.Models { [XmlType("evidence-identity")] [ProtoContract] - public class EvidenceIdentity + public class EvidenceIdentity : BomEntity, IBomEntityWithRefLinkType_StringList { [ProtoContract] public enum EvidenceFieldType @@ -67,5 +70,22 @@ public enum EvidenceFieldType [XmlElement("tools")] [ProtoMember(4)] public EvidenceTools Tools { get; set; } + + private static readonly ImmutableDictionary> RefLinkConstraints_List_AnyBomEntity = + new Dictionary> + { + // EvidenceTools is a List as of CDX spec 1.5 + { typeof(EvidenceIdentity).GetProperty("Tools", typeof(EvidenceTools)), RefLinkConstraints_AnyBomEntity } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + // TODO: switch/case for CDX spec newer than 1.5 where this type got introduced + if (specificationVersion == v1_5) + { + return RefLinkConstraints_List_AnyBomEntity; + } + return null; + } } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/EvidenceMethods.cs b/src/CycloneDX.Core/Models/EvidenceMethods.cs index dce4170e..6b0898ba 100644 --- a/src/CycloneDX.Core/Models/EvidenceMethods.cs +++ b/src/CycloneDX.Core/Models/EvidenceMethods.cs @@ -28,7 +28,7 @@ namespace CycloneDX.Models { [XmlType("evidence-methods")] [ProtoContract] - public class EvidenceMethods + public class EvidenceMethods : BomEntity { [ProtoContract] public enum EvidenceTechnique diff --git a/src/CycloneDX.Core/Models/EvidenceOccurrence.cs b/src/CycloneDX.Core/Models/EvidenceOccurrence.cs index 126ce97a..400fab03 100644 --- a/src/CycloneDX.Core/Models/EvidenceOccurrence.cs +++ b/src/CycloneDX.Core/Models/EvidenceOccurrence.cs @@ -28,7 +28,7 @@ namespace CycloneDX.Models { [XmlType("evidence-occurrence")] [ProtoContract] - public class EvidenceOccurrence + public class EvidenceOccurrence : BomEntity, IBomEntityWithRefType_String_BomRef { [JsonPropertyName("bom-ref")] [XmlAttribute("bom-ref")] diff --git a/src/CycloneDX.Core/Models/ExternalReference.cs b/src/CycloneDX.Core/Models/ExternalReference.cs index e84ef997..a84eb4b0 100644 --- a/src/CycloneDX.Core/Models/ExternalReference.cs +++ b/src/CycloneDX.Core/Models/ExternalReference.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [SuppressMessage("Microsoft.Naming", "CA1707:IdentifiersShouldNotContainUnderscores")] [ProtoContract] - public class ExternalReference + public class ExternalReference : BomEntity { [ProtoContract] public enum ExternalReferenceType @@ -125,5 +125,22 @@ public enum ExternalReferenceType [ProtoMember(4)] public List Hashes { get; set; } public bool ShouldSerializeHashes() { return Hashes?.Count > 0; } + + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.Url, o?.Type), + null); + } } } diff --git a/src/CycloneDX.Core/Models/Formula.cs b/src/CycloneDX.Core/Models/Formula.cs index d0b5ffed..104b7330 100644 --- a/src/CycloneDX.Core/Models/Formula.cs +++ b/src/CycloneDX.Core/Models/Formula.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("formula")] [ProtoContract] - public class Formula + public class Formula : BomEntity, IBomEntityWithRefType_String_BomRef { [JsonPropertyName("bom-ref")] [XmlAttribute("bom-ref")] diff --git a/src/CycloneDX.Core/Models/GraphicsCollection.cs b/src/CycloneDX.Core/Models/GraphicsCollection.cs index cc35bb19..200f95e0 100644 --- a/src/CycloneDX.Core/Models/GraphicsCollection.cs +++ b/src/CycloneDX.Core/Models/GraphicsCollection.cs @@ -26,10 +26,10 @@ namespace CycloneDX.Models { [XmlType("graphics")] [ProtoContract] - public class GraphicsCollection + public class GraphicsCollection : BomEntity { [ProtoContract] - public class Graphic + public class Graphic : BomEntity { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Hash.cs b/src/CycloneDX.Core/Models/Hash.cs index 567f8446..253442b9 100644 --- a/src/CycloneDX.Core/Models/Hash.cs +++ b/src/CycloneDX.Core/Models/Hash.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [XmlType("hash")] [ProtoContract] - public class Hash + public class Hash : BomEntity { [ProtoContract] public enum HashAlgorithm @@ -62,5 +62,43 @@ public enum HashAlgorithm [XmlText] [ProtoMember(2)] public string Content { get; set; } + + public bool Equivalent(Hash obj) + { + return (!(obj is null) && this.Alg == obj.Alg); + } + + /// + /// See BomEntity.MergeWith() + /// + public bool MergeWith(Hash obj, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + try + { + // Basic checks for null, type compatibility, + // equality and non-equivalence; throws for + // the hard stuff to implement in the catch: + return base.MergeWith(obj, listMergeHelperStrategy); + } + catch (BomEntityConflictException) + { + // Note: Alg is non-nullable so no check for that + if (this.Content is null && !(obj.Content is null)) + { + this.Content = obj.Content; + return true; + } + + if (this.Content != obj.Content) + { + throw new BomEntityConflictException($"Two Hash objects with same Alg='{this.Alg}' and different Content: '{this.Content}' vs. '{obj.Content}'"); + } + + // All known properties merged or were equal/equivalent + return true; + } + + // Should not get here + } } } \ No newline at end of file diff --git a/src/CycloneDX.Core/Models/IdentifiableAction.cs b/src/CycloneDX.Core/Models/IdentifiableAction.cs index 8cc58365..205e65b2 100644 --- a/src/CycloneDX.Core/Models/IdentifiableAction.cs +++ b/src/CycloneDX.Core/Models/IdentifiableAction.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class IdentifiableAction + public class IdentifiableAction : BomEntity { private DateTime? _timestamp; [XmlElement("timestamp")] diff --git a/src/CycloneDX.Core/Models/Input.cs b/src/CycloneDX.Core/Models/Input.cs index d8366765..4d26bb99 100644 --- a/src/CycloneDX.Core/Models/Input.cs +++ b/src/CycloneDX.Core/Models/Input.cs @@ -27,7 +27,7 @@ namespace CycloneDX.Models { [XmlType("input")] [ProtoContract] - public class Input + public class Input : BomEntity { [XmlElement("resource")] [ProtoMember(3)] diff --git a/src/CycloneDX.Core/Models/Issue.cs b/src/CycloneDX.Core/Models/Issue.cs index d8f3f584..591a6eb8 100644 --- a/src/CycloneDX.Core/Models/Issue.cs +++ b/src/CycloneDX.Core/Models/Issue.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Issue + public class Issue : BomEntity { [ProtoContract] public enum IssueClassification diff --git a/src/CycloneDX.Core/Models/License.cs b/src/CycloneDX.Core/Models/License.cs index 00edcc72..6cf10293 100644 --- a/src/CycloneDX.Core/Models/License.cs +++ b/src/CycloneDX.Core/Models/License.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [XmlType("license")] [ProtoContract] - public class License + public class License : BomEntity, IBomEntityWithRefType_String_BomRef { [XmlElement("id")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/LicenseChoice.cs b/src/CycloneDX.Core/Models/LicenseChoice.cs index 558003ea..465a7731 100644 --- a/src/CycloneDX.Core/Models/LicenseChoice.cs +++ b/src/CycloneDX.Core/Models/LicenseChoice.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class LicenseChoice + public class LicenseChoice : BomEntity, IBomEntityWithRefType_String_BomRef { [XmlElement("license")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Licensing.cs b/src/CycloneDX.Core/Models/Licensing.cs index ac62118a..ee6eb079 100644 --- a/src/CycloneDX.Core/Models/Licensing.cs +++ b/src/CycloneDX.Core/Models/Licensing.cs @@ -27,7 +27,7 @@ namespace CycloneDX.Models [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] [XmlType("licensing")] [ProtoContract] - public class Licensing + public class Licensing : BomEntity { [ProtoContract] public enum LicenseType diff --git a/src/CycloneDX.Core/Models/Lifecycles.cs b/src/CycloneDX.Core/Models/Lifecycles.cs index 48165d47..99ab9531 100644 --- a/src/CycloneDX.Core/Models/Lifecycles.cs +++ b/src/CycloneDX.Core/Models/Lifecycles.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Lifecycles + public class Lifecycles : BomEntity { [ProtoContract] public enum LifecyclePhase diff --git a/src/CycloneDX.Core/Models/Metadata.cs b/src/CycloneDX.Core/Models/Metadata.cs index 8c6bba22..829b4509 100644 --- a/src/CycloneDX.Core/Models/Metadata.cs +++ b/src/CycloneDX.Core/Models/Metadata.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Metadata + public class Metadata : BomEntity { private DateTime? _timestamp; [XmlElement("timestamp")] diff --git a/src/CycloneDX.Core/Models/ModelCard.cs b/src/CycloneDX.Core/Models/ModelCard.cs index 00afe51e..edcbdda9 100644 --- a/src/CycloneDX.Core/Models/ModelCard.cs +++ b/src/CycloneDX.Core/Models/ModelCard.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("modelCard")] [ProtoContract] - public class ModelCard + public class ModelCard : BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum ModelParameterApproachType @@ -44,13 +44,13 @@ public enum ModelParameterApproachType } [ProtoContract] - public class ModelCardQuantitativeAnalysis + public class ModelCardQuantitativeAnalysis : BomEntity { [ProtoContract] - public class PerformanceMetric + public class PerformanceMetric : BomEntity { [ProtoContract] - public class PerformanceMetricConfidenceInterval + public class PerformanceMetricConfidenceInterval : BomEntity { [XmlElement("lowerBound")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ModelCardConsiderations.cs b/src/CycloneDX.Core/Models/ModelCardConsiderations.cs index d42c88b7..4f50054d 100644 --- a/src/CycloneDX.Core/Models/ModelCardConsiderations.cs +++ b/src/CycloneDX.Core/Models/ModelCardConsiderations.cs @@ -26,10 +26,10 @@ namespace CycloneDX.Models { [XmlType("modelCardConsiderations")] [ProtoContract] - public class ModelCardConsiderations + public class ModelCardConsiderations : BomEntity { [ProtoContract] - public class ModelCardEthicalConsideration + public class ModelCardEthicalConsideration : BomEntity { [XmlElement("name")] [ProtoMember(1)] @@ -41,7 +41,7 @@ public class ModelCardEthicalConsideration } [ProtoContract] - public class ModelCardFairnessAssessment + public class ModelCardFairnessAssessment : BomEntity { [XmlElement("groupAtRisk")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ModelParameters.cs b/src/CycloneDX.Core/Models/ModelParameters.cs index a568bb54..f2ce7e86 100644 --- a/src/CycloneDX.Core/Models/ModelParameters.cs +++ b/src/CycloneDX.Core/Models/ModelParameters.cs @@ -26,10 +26,10 @@ namespace CycloneDX.Models { [XmlType("model-parameters")] [ProtoContract] - public class ModelParameters + public class ModelParameters : BomEntity { [ProtoContract] - public class ModelApproach + public class ModelApproach : BomEntity { [XmlElement("type")] [ProtoMember(1)] @@ -37,7 +37,7 @@ public class ModelApproach } [ProtoContract] - public class ModelDataset + public class ModelDataset : BomEntity { [XmlElement("dataset")] [ProtoMember(1)] @@ -49,7 +49,7 @@ public class ModelDataset } [ProtoContract] - public class MachineLearningInputOutputParameter + public class MachineLearningInputOutputParameter : BomEntity { [XmlElement("format")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Note.cs b/src/CycloneDX.Core/Models/Note.cs index feaea13e..66fd3292 100644 --- a/src/CycloneDX.Core/Models/Note.cs +++ b/src/CycloneDX.Core/Models/Note.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Note + public class Note : BomEntity { [XmlElement("locale")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/OrganizationalContact.cs b/src/CycloneDX.Core/Models/OrganizationalContact.cs index de677b62..11c7a853 100644 --- a/src/CycloneDX.Core/Models/OrganizationalContact.cs +++ b/src/CycloneDX.Core/Models/OrganizationalContact.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class OrganizationalContact + public class OrganizationalContact : BomEntity, IBomEntityWithRefType_String_BomRef { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/OrganizationalEntity.cs b/src/CycloneDX.Core/Models/OrganizationalEntity.cs index 77f60c03..42602592 100644 --- a/src/CycloneDX.Core/Models/OrganizationalEntity.cs +++ b/src/CycloneDX.Core/Models/OrganizationalEntity.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class OrganizationalEntity + public class OrganizationalEntity : BomEntity, IBomEntityWithRefType_String_BomRef { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/OrganizationalEntityOrContact.cs b/src/CycloneDX.Core/Models/OrganizationalEntityOrContact.cs index 9db9fb40..8439edc6 100644 --- a/src/CycloneDX.Core/Models/OrganizationalEntityOrContact.cs +++ b/src/CycloneDX.Core/Models/OrganizationalEntityOrContact.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class OrganizationalEntityOrContact + public class OrganizationalEntityOrContact : BomEntity { [XmlElement("organization")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Output.cs b/src/CycloneDX.Core/Models/Output.cs index 001e227b..f41cc94b 100644 --- a/src/CycloneDX.Core/Models/Output.cs +++ b/src/CycloneDX.Core/Models/Output.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("output")] [ProtoContract] - public class Output + public class Output : BomEntity { [ProtoContract] public enum OutputType diff --git a/src/CycloneDX.Core/Models/Parameter.cs b/src/CycloneDX.Core/Models/Parameter.cs index 4de2b485..2427712f 100644 --- a/src/CycloneDX.Core/Models/Parameter.cs +++ b/src/CycloneDX.Core/Models/Parameter.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Parameter + public class Parameter : BomEntity { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Patch.cs b/src/CycloneDX.Core/Models/Patch.cs index cdc5e2cf..1a573574 100644 --- a/src/CycloneDX.Core/Models/Patch.cs +++ b/src/CycloneDX.Core/Models/Patch.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Patch + public class Patch : BomEntity { [ProtoContract] public enum PatchClassification diff --git a/src/CycloneDX.Core/Models/Pedigree.cs b/src/CycloneDX.Core/Models/Pedigree.cs index 537d6e87..17609381 100644 --- a/src/CycloneDX.Core/Models/Pedigree.cs +++ b/src/CycloneDX.Core/Models/Pedigree.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Pedigree + public class Pedigree : BomEntity { [XmlArray("ancestors")] [XmlArrayItem("component")] diff --git a/src/CycloneDX.Core/Models/Property.cs b/src/CycloneDX.Core/Models/Property.cs index a61b45f2..5217e5d2 100644 --- a/src/CycloneDX.Core/Models/Property.cs +++ b/src/CycloneDX.Core/Models/Property.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Property + public class Property : BomEntity { [XmlAttribute("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ReleaseNotes.cs b/src/CycloneDX.Core/Models/ReleaseNotes.cs index b8a33ce9..d20f8d88 100644 --- a/src/CycloneDX.Core/Models/ReleaseNotes.cs +++ b/src/CycloneDX.Core/Models/ReleaseNotes.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class ReleaseNotes + public class ReleaseNotes : BomEntity { [XmlElement("type")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ResourceReferenceChoice.cs b/src/CycloneDX.Core/Models/ResourceReferenceChoice.cs index dc8153b4..1cf78e56 100644 --- a/src/CycloneDX.Core/Models/ResourceReferenceChoice.cs +++ b/src/CycloneDX.Core/Models/ResourceReferenceChoice.cs @@ -15,6 +15,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; using System.Text.Json.Serialization; using System.Xml; using System.Xml.Serialization; @@ -23,7 +28,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class ResourceReferenceChoice : IXmlSerializable + public class ResourceReferenceChoice : BomEntity, IXmlSerializable, IBomEntityWithRefLinkType_String_Ref { private static XmlSerializer _extRefSerializer; private static XmlSerializer GetExternalReferenceSerializer() @@ -72,5 +77,21 @@ public void WriteXml(XmlWriter writer) { GetExternalReferenceSerializer().Serialize(writer, this.ExternalReference); } } + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_AnyBomEntity = + new Dictionary> + { + { typeof(ResourceReferenceChoice).GetProperty("Ref", typeof(string)), RefLinkConstraints_AnyBomEntity } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + // TODO: switch/case for CDX spec newer than 1.5 where this type got introduced + if (specificationVersion == v1_5) + { + return RefLinkConstraints_StringRef_AnyBomEntity; + } + return null; + } } } diff --git a/src/CycloneDX.Core/Models/Service.cs b/src/CycloneDX.Core/Models/Service.cs index fda223f7..16630f6c 100644 --- a/src/CycloneDX.Core/Models/Service.cs +++ b/src/CycloneDX.Core/Models/Service.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Service: IEquatable + public class Service : BomEntity, IBomEntityWithRefType_String_BomRef { public Service() { @@ -191,17 +191,23 @@ public ServiceDataChoices XmlData [XmlArrayItem("property")] [ProtoMember(14)] public List Properties { get; set; } - public bool ShouldSerializeProperties() => Properties?.Count > 0; - public bool Equals(Service obj) - { - return CycloneDX.Json.Serializer.Serialize(this) == CycloneDX.Json.Serializer.Serialize(obj); - } - - public override int GetHashCode() + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) { - return CycloneDX.Json.Serializer.Serialize(this).GetHashCode(); + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.BomRef, o?.Group, o?.Name, o?.Version), + null); } } } diff --git a/src/CycloneDX.Core/Models/ServiceDataChoices.cs b/src/CycloneDX.Core/Models/ServiceDataChoices.cs index 51a64076..0ace05c2 100644 --- a/src/CycloneDX.Core/Models/ServiceDataChoices.cs +++ b/src/CycloneDX.Core/Models/ServiceDataChoices.cs @@ -25,7 +25,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class ServiceDataChoices : IXmlSerializable + public class ServiceDataChoices : BomEntity, IXmlSerializable { internal SpecificationVersion SpecVersion { get; set; } diff --git a/src/CycloneDX.Core/Models/Source.cs b/src/CycloneDX.Core/Models/Source.cs index 1c6bbead..70c781fd 100644 --- a/src/CycloneDX.Core/Models/Source.cs +++ b/src/CycloneDX.Core/Models/Source.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Source + public class Source : BomEntity { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Step.cs b/src/CycloneDX.Core/Models/Step.cs index 962c1e1e..b52a28dd 100644 --- a/src/CycloneDX.Core/Models/Step.cs +++ b/src/CycloneDX.Core/Models/Step.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("step")] [ProtoContract] - public class Step + public class Step : BomEntity { [XmlElement("name")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Swid.cs b/src/CycloneDX.Core/Models/Swid.cs index 4a0ccac2..f0e0756f 100644 --- a/src/CycloneDX.Core/Models/Swid.cs +++ b/src/CycloneDX.Core/Models/Swid.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class Swid + public class Swid : BomEntity { [XmlAttribute("tagId")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Tool.cs b/src/CycloneDX.Core/Models/Tool.cs index 98349490..9025fb2d 100644 --- a/src/CycloneDX.Core/Models/Tool.cs +++ b/src/CycloneDX.Core/Models/Tool.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [Obsolete("Tool is deprecated and will be removed in a future version")] [ProtoContract] - public class Tool: IEquatable + public class Tool : BomEntity { [XmlElement("vendor")] [ProtoMember(1)] @@ -48,14 +48,21 @@ public class Tool: IEquatable public List ExternalReferences { get; set; } public bool ShouldSerializeExternalReferences() { return ExternalReferences?.Count > 0; } - public bool Equals(Tool obj) + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) { - return CycloneDX.Json.Serializer.Serialize(this) == CycloneDX.Json.Serializer.Serialize(obj); - } - - public override int GetHashCode() - { - return CycloneDX.Json.Serializer.Serialize(this).GetHashCode(); + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.Vendor, o?.Name, o?.Version), + null); } } -} \ No newline at end of file +} diff --git a/src/CycloneDX.Core/Models/ToolChoices.cs b/src/CycloneDX.Core/Models/ToolChoices.cs index e9059e57..3f56d952 100644 --- a/src/CycloneDX.Core/Models/ToolChoices.cs +++ b/src/CycloneDX.Core/Models/ToolChoices.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models { [ProtoContract] - public class ToolChoices : IXmlSerializable + public class ToolChoices : BomEntity, IXmlSerializable { internal SpecificationVersion SpecVersion { get; set; } diff --git a/src/CycloneDX.Core/Models/Trigger.cs b/src/CycloneDX.Core/Models/Trigger.cs index 5b8407b2..7d6c3528 100644 --- a/src/CycloneDX.Core/Models/Trigger.cs +++ b/src/CycloneDX.Core/Models/Trigger.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("trigger")] [ProtoContract] - public class Trigger + public class Trigger : BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum TriggerType @@ -42,7 +42,7 @@ public enum TriggerType } [ProtoContract] - public class Condition + public class Condition : BomEntity { [XmlElement("description")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/ValidationResult.cs b/src/CycloneDX.Core/Models/ValidationResult.cs index 2580f86c..b1bb313e 100644 --- a/src/CycloneDX.Core/Models/ValidationResult.cs +++ b/src/CycloneDX.Core/Models/ValidationResult.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models /// /// The return type for all validation methods. /// - public class ValidationResult + public class ValidationResult : BomEntity { /// /// true if the document has been successfully validated. diff --git a/src/CycloneDX.Core/Models/Volume.cs b/src/CycloneDX.Core/Models/Volume.cs index 25a76eeb..d4ed0432 100644 --- a/src/CycloneDX.Core/Models/Volume.cs +++ b/src/CycloneDX.Core/Models/Volume.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("volume")] [ProtoContract] - public class Volume + public class Volume : BomEntity { [ProtoContract] public enum VolumeMode diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Advisory.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Advisory.cs index 267cb4fa..3e9f6af5 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Advisory.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Advisory.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Advisory + public class Advisory : BomEntity { [XmlElement("title")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/AffectedVersions.cs b/src/CycloneDX.Core/Models/Vulnerabilities/AffectedVersions.cs index f0fc31bc..c80aeca8 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/AffectedVersions.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/AffectedVersions.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class AffectedVersions + public class AffectedVersions : BomEntity { [XmlElement("version")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Affects.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Affects.cs index 3dfe543e..dd4b6b30 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Affects.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Affects.cs @@ -15,7 +15,11 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. +using static CycloneDX.SpecificationVersion; +using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; using System.Text.Json.Serialization; using System.Xml.Serialization; using ProtoBuf; @@ -23,7 +27,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Affects + public class Affects : BomEntity, IBomEntityWithRefLinkType_String_Ref { [XmlElement("ref")] [ProtoMember(1)] @@ -34,5 +38,28 @@ public class Affects [JsonPropertyName("versions")] [ProtoMember(2)] public List Versions { get; set; } + + private static readonly ImmutableDictionary> RefLinkConstraints_StringRef_ComponentOrService = + new Dictionary> + { + { typeof(Affects).GetProperty("Ref", typeof(string)), RefLinkConstraints_ComponentOrService } + }.ToImmutableDictionary(); + + public ImmutableDictionary> GetRefLinkConstraints(SpecificationVersion specificationVersion) + { + switch (specificationVersion) + { + case v1_0: + case v1_1: + case v1_2: + case v1_3: + return null; + + case v1_4: + case v1_5: + default: + return RefLinkConstraints_StringRef_ComponentOrService; + } + } } } diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Analysis.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Analysis.cs index c0411f46..594a7a05 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Analysis.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Analysis.cs @@ -23,7 +23,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Analysis + public class Analysis : BomEntity { [XmlElement("state")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Credits.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Credits.cs index 93603711..dc73460a 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Credits.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Credits.cs @@ -22,7 +22,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Credits + public class Credits : BomEntity { [XmlArray("organizations")] [XmlArrayItem("organization")] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/ProofOfConcept.cs b/src/CycloneDX.Core/Models/Vulnerabilities/ProofOfConcept.cs index 9017d166..dea429c3 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/ProofOfConcept.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/ProofOfConcept.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class ProofOfConcept + public class ProofOfConcept : BomEntity { [XmlElement("reproductionSteps")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Rating.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Rating.cs index acb2be22..60f90628 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Rating.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Rating.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Rating + public class Rating : BomEntity { [XmlElement("source")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Reference.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Reference.cs index 53a0aa16..8c584c4b 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Reference.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Reference.cs @@ -21,7 +21,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Reference + public class Reference : BomEntity { [XmlElement("id")] [ProtoMember(1)] diff --git a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs index 205d263d..d7de6726 100644 --- a/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs +++ b/src/CycloneDX.Core/Models/Vulnerabilities/Vulnerability.cs @@ -24,7 +24,7 @@ namespace CycloneDX.Models.Vulnerabilities { [ProtoContract] - public class Vulnerability: IEquatable + public class Vulnerability : BomEntity, IBomEntityWithRefType_String_BomRef { [XmlAttribute("bom-ref")] [JsonPropertyName("bom-ref")] @@ -143,15 +143,22 @@ public DateTime? Rejected [ProtoMember(18)] public List Properties { get; set; } public bool ShouldSerializeProperties() { return Properties?.Count > 0; } - - public bool Equals(Vulnerability obj) - { - return CycloneDX.Json.Serializer.Serialize(this) == CycloneDX.Json.Serializer.Serialize(obj); - } - - public override int GetHashCode() + + /// + /// See BomEntity.NormalizeList() and ListMergeHelper.SortByImpl(). + /// Note that as a static method this is not exactly an "override", + /// but the BomEntity base class implementation makes it behave + /// like that in practice. + /// + /// Ascending (true) or Descending (false) + /// Passed to BomEntity.NormalizeList() (effective if recursing), not handled right here + /// List to sort + public static void NormalizeList(bool ascending, bool recursive, List list) { - return CycloneDX.Json.Serializer.Serialize(this).GetHashCode(); + var sortHelper = new ListMergeHelper(); + sortHelper.SortByImpl(ascending, recursive, list, + o => (o?.BomRef, o?.Id, o?.Created, o?.Updated), + null); } } } diff --git a/src/CycloneDX.Core/Models/Workflow.cs b/src/CycloneDX.Core/Models/Workflow.cs index b74a9cd2..40439f13 100644 --- a/src/CycloneDX.Core/Models/Workflow.cs +++ b/src/CycloneDX.Core/Models/Workflow.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("workflow")] [ProtoContract] - public class Workflow + public class Workflow : BomEntity, IBomEntityWithRefType_String_BomRef { [JsonPropertyName("bom-ref")] [XmlAttribute("bom-ref")] diff --git a/src/CycloneDX.Core/Models/WorkflowTask.cs b/src/CycloneDX.Core/Models/WorkflowTask.cs index 9eb0d514..8e089634 100644 --- a/src/CycloneDX.Core/Models/WorkflowTask.cs +++ b/src/CycloneDX.Core/Models/WorkflowTask.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("task")] [ProtoContract] - public class WorkflowTask + public class WorkflowTask : BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum TaskType diff --git a/src/CycloneDX.Core/Models/Workspace.cs b/src/CycloneDX.Core/Models/Workspace.cs index ccc43f79..23931eef 100644 --- a/src/CycloneDX.Core/Models/Workspace.cs +++ b/src/CycloneDX.Core/Models/Workspace.cs @@ -26,7 +26,7 @@ namespace CycloneDX.Models { [XmlType("workspace")] [ProtoContract] - public class Workspace + public class Workspace : BomEntity, IBomEntityWithRefType_String_BomRef { [ProtoContract] public enum AccessModeType diff --git a/src/CycloneDX.Utils/Merge.cs b/src/CycloneDX.Utils/Merge.cs index da18352c..42e1843f 100644 --- a/src/CycloneDX.Utils/Merge.cs +++ b/src/CycloneDX.Utils/Merge.cs @@ -1,397 +1,869 @@ -// 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; -using System.Collections.Generic; -using CycloneDX.Models; -using CycloneDX.Models.Vulnerabilities; -using CycloneDX.Utils.Exceptions; - -namespace CycloneDX.Utils -{ - class ListMergeHelper - { - public List Merge(List list1, List list2) - { - if (list1 is null) return list2; - if (list2 is null) return list1; - - var result = new List(list1); - - foreach (var item in list2) - { - if (!(result.Contains(item))) - { - result.Add(item); - } - } - - return result; - } - } - - public static partial class CycloneDXUtils - { - /// - /// Performs a flat merge of two BOMs. - /// - /// Useful for situations like building a consolidated BOM for a web - /// application. Flat merge can combine the BOM for frontend code - /// with the BOM for backend code and return a single, combined BOM. - /// - /// For situations where system component hierarchy is required to be - /// maintained refer to the HierarchicalMerge method. - /// - /// - /// - /// - public static Bom FlatMerge(Bom bom1, Bom bom2) - { - var result = new Bom(); - - #pragma warning disable 618 - var toolsMerger = new ListMergeHelper(); - #pragma warning restore 618 - var tools = toolsMerger.Merge(bom1.Metadata?.Tools?.Tools, bom2.Metadata?.Tools?.Tools); - if (tools != null) - { - result.Metadata = new Metadata - { - Tools = new ToolChoices - { - Tools = tools, - } - }; - } - - var componentsMerger = new ListMergeHelper(); - result.Components = componentsMerger.Merge(bom1.Components, bom2.Components); - - //Add main component if missing - if (result.Components != null && !(bom2.Metadata?.Component is null) && !result.Components.Contains(bom2.Metadata.Component)) - { - result.Components.Add(bom2.Metadata.Component); - } - - var servicesMerger = new ListMergeHelper(); - result.Services = servicesMerger.Merge(bom1.Services, bom2.Services); - - var extRefsMerger = new ListMergeHelper(); - result.ExternalReferences = extRefsMerger.Merge(bom1.ExternalReferences, bom2.ExternalReferences); - - var dependenciesMerger = new ListMergeHelper(); - result.Dependencies = dependenciesMerger.Merge(bom1.Dependencies, bom2.Dependencies); - - var compositionsMerger = new ListMergeHelper(); - result.Compositions = compositionsMerger.Merge(bom1.Compositions, bom2.Compositions); - - var vulnerabilitiesMerger = new ListMergeHelper(); - result.Vulnerabilities = vulnerabilitiesMerger.Merge(bom1.Vulnerabilities, bom2.Vulnerabilities); - - return result; - } - - - /// - /// Performs a flat merge of multiple BOMs. - /// - /// Useful for situations like building a consolidated BOM for a web - /// application. Flat merge can combine the BOM for frontend code - /// with the BOM for backend code and return a single, combined BOM. - /// - /// For situations where system component hierarchy is required to be - /// maintained refer to the HierarchicalMerge method. - /// - /// - /// - /// - public static Bom FlatMerge(IEnumerable boms) - { - return FlatMerge(boms, null); - } - - /// - /// Performs a flat merge of multiple BOMs. - /// - /// Useful for situations like building a consolidated BOM for a web - /// application. Flat merge can combine the BOM for frontend code - /// with the BOM for backend code and return a single, combined BOM. - /// - /// For situations where system component hierarchy is required to be - /// maintained refer to the HierarchicalMerge method. - /// - /// - /// - /// - public static Bom FlatMerge(IEnumerable boms, Component bomSubject) - { - var result = new Bom(); - - foreach (var bom in boms) - { - result = FlatMerge(result, bom); - } - - if (bomSubject != null) - { - // use the params provided if possible - result.Metadata.Component = bomSubject; - result.Metadata.Component.BomRef = ComponentBomRefNamespace(result.Metadata.Component); - - var mainDependency = new Dependency(); - mainDependency.Ref = result.Metadata.Component.BomRef; - mainDependency.Dependencies = new List(); - - foreach (var bom in boms) - { - if (!(bom.Metadata?.Component is null)) - { - var dep = new Dependency(); - dep.Ref = bom.Metadata.Component.BomRef; - - mainDependency.Dependencies.Add(dep); - } - } - - result.Dependencies.Add(mainDependency); - - - } - - return result; - } - - /// - /// Performs a hierarchical merge for multiple BOMs. - /// - /// To retain system component hierarchy, top level BOM metadata - /// component must be included in each BOM. - /// - /// - /// - /// The component described by the hierarchical merge being performed. - /// - /// This will be included as the top level BOM metadata component in - /// the returned BOM. - /// - /// - public static Bom HierarchicalMerge(IEnumerable boms, Component bomSubject) - { - var result = new Bom(); - if (bomSubject != null) - { - if (bomSubject.BomRef is null) bomSubject.BomRef = ComponentBomRefNamespace(bomSubject); - result.Metadata = new Metadata - { - Component = bomSubject, - #pragma warning disable 618 - Tools = new ToolChoices - { - Tools = new List(), - } - #pragma warning restore 618 - }; - } - - result.Components = new List(); - result.Services = new List(); - result.ExternalReferences = new List(); - result.Dependencies = new List(); - result.Compositions = new List(); - result.Vulnerabilities = new List(); - - var bomSubjectDependencies = new List(); - - foreach (var bom in boms) - { - if (bom.Metadata?.Component is null) - { - throw new MissingMetadataComponentException( - bom.SerialNumber is null - ? "Required metadata (top level) component is missing from BOM." - : $"Required metadata (top level) component is missing from BOM {bom.SerialNumber}."); - } - - if (bom.Metadata?.Tools?.Tools?.Count > 0) - { - result.Metadata.Tools.Tools.AddRange(bom.Metadata.Tools.Tools); - } - - var thisComponent = bom.Metadata.Component; - if (thisComponent.Components is null) bom.Metadata.Component.Components = new List(); - if (!(bom.Components is null)) - { - thisComponent.Components.AddRange(bom.Components); - } - - // add a namespace to existing BOM refs - NamespaceComponentBomRefs(thisComponent); - - // make sure we have a BOM ref set and add top level dependency reference - if (thisComponent.BomRef is null) thisComponent.BomRef = ComponentBomRefNamespace(thisComponent); - bomSubjectDependencies.Add(new Dependency { Ref = thisComponent.BomRef }); - - result.Components.Add(thisComponent); - - - // services - if (bom.Services != null) - foreach (var service in bom.Services) - { - service.BomRef = NamespacedBomRef(bom.Metadata.Component, service.BomRef); - result.Services.Add(service); - } - - // external references - if (!(bom.ExternalReferences is null)) result.ExternalReferences.AddRange(bom.ExternalReferences); - - // dependencies - if (bom.Dependencies != null) - { - NamespaceDependencyBomRefs(ComponentBomRefNamespace(thisComponent), bom.Dependencies); - result.Dependencies.AddRange(bom.Dependencies); - } - - // compositions - if (bom.Compositions != null) - { - NamespaceCompositions(ComponentBomRefNamespace(bom.Metadata.Component), bom.Compositions); - result.Compositions.AddRange(bom.Compositions); - } - - // vulnerabilities - if (bom.Vulnerabilities != null) - { - NamespaceVulnerabilitiesRefs(ComponentBomRefNamespace(result.Metadata.Component), bom.Vulnerabilities); - result.Vulnerabilities.AddRange(bom.Vulnerabilities); - } - } - - if (bomSubject != null) - { - result.Dependencies.Add( new Dependency - { - Ref = result.Metadata.Component.BomRef, - Dependencies = bomSubjectDependencies - }); - } - - // cleanup empty top level elements - if (result.Metadata.Tools.Tools.Count == 0) result.Metadata.Tools.Tools = null; - if (result.Components.Count == 0) result.Components = null; - if (result.Services.Count == 0) result.Services = null; - if (result.ExternalReferences.Count == 0) result.ExternalReferences = null; - if (result.Dependencies.Count == 0) result.Dependencies = null; - if (result.Compositions.Count == 0) result.Compositions = null; - if (result.Vulnerabilities.Count == 0) result.Vulnerabilities = null; - - return result; - } - - private static string NamespacedBomRef(Component bomSubject, string bomRef) - { - return string.IsNullOrEmpty(bomRef) ? null : NamespacedBomRef(ComponentBomRefNamespace(bomSubject), bomRef); - } - - private static string NamespacedBomRef(string bomRefNamespace, string bomRef) - { - return string.IsNullOrEmpty(bomRef) ? null : $"{bomRefNamespace}:{bomRef}"; - } - - private static string ComponentBomRefNamespace(Component component) - { - return component.Group is null - ? $"{component.Name}@{component.Version}" - : $"{component.Group}.{component.Name}@{component.Version}"; - } - - private static void NamespaceComponentBomRefs(Component topComponent) - { - var components = new Stack(); - components.Push(topComponent); - - while (components.Count > 0) - { - var currentComponent = components.Pop(); - - if (currentComponent.Components != null) - foreach (var subComponent in currentComponent.Components) - { - components.Push(subComponent); - } - - currentComponent.BomRef = NamespacedBomRef(topComponent, currentComponent.BomRef); - } - } - - private static void NamespaceVulnerabilitiesRefs(string bomRefNamespace, List vulnerabilities) - { - var pendingVulnerabilities = new Stack(vulnerabilities); - - while (pendingVulnerabilities.Count > 0) - { - var vulnerability = pendingVulnerabilities.Pop(); - - vulnerability.BomRef = NamespacedBomRef(bomRefNamespace, vulnerability.BomRef); - - if (vulnerability.Affects != null) - { - foreach (var affect in vulnerability.Affects) - { - affect.Ref = bomRefNamespace; - } - } - } - } - - private static void NamespaceDependencyBomRefs(string bomRefNamespace, List dependencies) - { - var pendingDependencies = new Stack(dependencies); - - while (pendingDependencies.Count > 0) - { - var dependency = pendingDependencies.Pop(); - - if (dependency.Dependencies != null) - foreach (var subDependency in dependency.Dependencies) - { - pendingDependencies.Push(subDependency); - } - - dependency.Ref = NamespacedBomRef(bomRefNamespace, dependency.Ref); - } - } - - private static void NamespaceCompositions(string bomRefNamespace, List compositions) - { - foreach (var composition in compositions) - { - if (composition.Assemblies != null) - for (var i=0; i + /// Performs a flat merge of two BOMs. + /// + /// Useful for situations like building a consolidated BOM for a web + /// application. Flat merge can combine the BOM for frontend code + /// with the BOM for backend code and return a single, combined BOM. + /// + /// For situations where system component hierarchy is required to be + /// maintained refer to the HierarchicalMerge method. + /// + /// + /// + /// + public static Bom FlatMerge(Bom bom1, Bom bom2) + { + return FlatMerge(bom1, bom2, BomEntityListMergeHelperStrategy.Default()); + } + + /// + /// Handle merging of two Bom object contents, possibly de-duplicating + /// or merging information from Equivalent() entries as further tuned + /// via listMergeHelperStrategy argument. + /// + /// NOTE: This sets a new timestamp into each newly merged Bom document. + /// However it is up to the caller to use Bom.BomMetadataReferThisToolkit() + /// for adding references to this library (and the run-time program + /// which consumes it) into the final merged document, to avoid the + /// overhead in a loop context. + /// + /// + /// + /// + /// + public static Bom FlatMerge(Bom bom1, Bom bom2, BomEntityListMergeHelperStrategy listMergeHelperStrategy) + { + if (!int.TryParse(System.Environment.GetEnvironmentVariable("CYCLONEDX_DEBUG_MERGE"), out int iDebugLevel) || iDebugLevel < 0) + { + iDebugLevel = 0; + } + + /* Initial use-case for BomWalkResult discoveries to see how they scale */ + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom1..."); + } + BomWalkResult bwr1 = bom1.WalkThis(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom1: got {bwr1}"); + } + Dictionary> dict1ByC = bwr1.GetBomRefsInContainers(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom1: got {dict1ByC.Count} BomRef-entity containers"); + } + Dictionary dict1 = bwr1.GetBomRefsWithContainer(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom1: got {dict1.Count} BomRefs"); + } + + BomWalkResult bwr2 = bom2.WalkThis(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom2: got {bwr2}"); + } + Dictionary> dict2ByC = bwr2.GetBomRefsInContainers(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom2: got {dict2ByC.Count} BomRef-entity containers"); + } + Dictionary dict2 = bwr2.GetBomRefsWithContainer(); + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: {DateTime.Now}: inspecting bom2: got {dict2.Count} BomRefs"); + } + + var result = new Bom(); + // Note: we recurse into this method from other FlatMerge() implementations + // (e.g. mass-merge of a big list of Bom documents), so the resulting + // document gets a new timestamp every time. It is unique after all. + // Also note that a merge of "new Bom()" with a real Bom is also different + // from that original (serialNumber, timestamp, possible entry order, etc.) + // Adding Tools[] entries to refer to this library (and the run-time tool + // program which consumes it) costs a bit more, so this is toggled separately + // and should not waste CPU not in a loop. + // Note that these toggles default to `false` so should not impact the + // typical loop (calls from the other FlatMerge() implementations nearby). + if (listMergeHelperStrategy.doBomMetadataUpdate) + { + result.BomMetadataUpdate(listMergeHelperStrategy.doBomMetadataUpdateNewSerialNumber); + } + if (listMergeHelperStrategy.doBomMetadataUpdateReferThisToolkit) + { + result.BomMetadataReferThisToolkit(); + } + if (result.Metadata is null) + { + // If none of the above... + result.Metadata = new Metadata(); + } + + #pragma warning disable 618 + var toolsMerger = new ListMergeHelper(); + #pragma warning restore 618 + var tools = toolsMerger.Merge(bom1.Metadata?.Tools?.Tools, bom2.Metadata?.Tools?.Tools, listMergeHelperStrategy); + if (tools != null) + { + if (result.Metadata.Tools == null) + { + result.Metadata.Tools = new ToolChoices(); + } + + if (result.Metadata.Tools.Tools != null) + { + tools = toolsMerger.Merge(result.Metadata.Tools.Tools, tools, listMergeHelperStrategy); + } + + result.Metadata.Tools.Tools = tools; + } + + var componentsMerger = new ListMergeHelper(); + result.Components = componentsMerger.Merge(bom1.Components, bom2.Components, listMergeHelperStrategy); + + // Add main component from bom2 as a "yet another component" + // if missing in that list so far. Note: any more complicated + // cases should be handled by CleanupMetadataComponent() when + // called by MergeCommand or similar consumer; however we can + // not generally rely in a library that only one particular + // tool calls it - so this method should ensure validity of + // its own output on every step along the way. + if (result.Components != null && !(bom2.Metadata?.Component is null) && !result.Components.Contains(bom2.Metadata.Component)) + { + // Skip such addition if the component in bom2 is same as the + // existing metadata/component in bom1 (gluing same file together + // twice should be effectively no-op); try to merge instead: + + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: bom1comp='{bom1.Metadata?.Component}' bom-ref1='{bom1.Metadata?.Component?.BomRef}' bom2comp='{bom2.Metadata?.Component}' bom-ref2='{bom2.Metadata?.Component?.BomRef}'"); + } + + if (!(bom1.Metadata?.Component is null) && (bom2.Metadata.Component.Equals(bom1.Metadata.Component) + || (!(bom1.Metadata?.Component?.BomRef is null) && !(bom2.Metadata?.Component?.BomRef is null) && (bom1.Metadata.Component.BomRef == bom2.Metadata.Component.BomRef)))) + { + // bom1's entry is not null and seems equivalent to bom2's: + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: bom1.Metadata.Component is already equivalent to bom2.Metadata.Component: merging"); + } + result.Metadata.Component = bom1.Metadata.Component; + result.Metadata.Component.MergeWith(bom2.Metadata.Component, listMergeHelperStrategy); + } + else + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"FLAT-MERGE: bom1.Metadata.Component is missing or not equivalent to bom2.Metadata.Component: adding new entry into components[]"); + } + result.Components.Add(bom2.Metadata.Component); + } + } + + var servicesMerger = new ListMergeHelper(); + result.Services = servicesMerger.Merge(bom1.Services, bom2.Services, listMergeHelperStrategy); + + var extRefsMerger = new ListMergeHelper(); + result.ExternalReferences = extRefsMerger.Merge(bom1.ExternalReferences, bom2.ExternalReferences, listMergeHelperStrategy); + + var dependenciesMerger = new ListMergeHelper(); + result.Dependencies = dependenciesMerger.Merge(bom1.Dependencies, bom2.Dependencies, listMergeHelperStrategy); + + var compositionsMerger = new ListMergeHelper(); + result.Compositions = compositionsMerger.Merge(bom1.Compositions, bom2.Compositions, listMergeHelperStrategy); + + var vulnerabilitiesMerger = new ListMergeHelper(); + result.Vulnerabilities = vulnerabilitiesMerger.Merge(bom1.Vulnerabilities, bom2.Vulnerabilities, listMergeHelperStrategy); + + result = CleanupMetadataComponent(result); + result = CleanupEmptyLists(result); + + return result; + } + + + /// + /// Performs a flat merge of multiple BOMs. + /// + /// Useful for situations like building a consolidated BOM for a web + /// application. Flat merge can combine the BOM for frontend code + /// with the BOM for backend code and return a single, combined BOM. + /// + /// For situations where system component hierarchy is required to be + /// maintained refer to the HierarchicalMerge method. + /// + /// + /// + /// + public static Bom FlatMerge(IEnumerable boms) + { + return FlatMerge(boms, null); + } + + /// + /// Performs a flat merge of multiple BOMs. + /// + /// Useful for situations like building a consolidated BOM for a web + /// application. Flat merge can combine the BOM for frontend code + /// with the BOM for backend code and return a single, combined BOM. + /// + /// For situations where system component hierarchy is required to be + /// maintained refer to the HierarchicalMerge method. + /// + /// + /// + /// + public static Bom FlatMerge(IEnumerable boms, Component bomSubject) + { + var result = new Bom(); + BomEntityListMergeHelperStrategy safeStrategy = BomEntityListMergeHelperStrategy.Default(); + BomEntityListMergeHelperStrategy quickStrategy = BomEntityListMergeHelperStrategy.Default(); + quickStrategy.useBomEntityMerge = false; + + // Sanity-check: we will do evil things in Components.MergeWith() + // among others, and hash-code based quick deduplication, which + // may potentially lead to loss of info. Keep track of "bom-ref" + // values we had incoming, and what we would see in the merged + // document eventually. + // TODO: Adapt if we would later rename conflicting entries on + // the fly. These dictionaries can help actually. See details in + // https://github.com/CycloneDX/cyclonedx-dotnet-library/pull/245#issuecomment-1686079370 + Dictionary dictBomRefsInput = CountBomRefs(result); + + // Note: we were asked to "merge" and so we do, per principle of + // least surprise - even if there is just one entry in boms[] so + // we might be inclined to skip the loop. Resulting document WILL + // differ from such single original (serialNumber, timestamp...) + int countBoms = 0; + foreach (var bom in boms) + { + // INJECTED-ERROR-FOR-TESTING // if countBoms > 1 then ... + CountBomRefs(bom, ref dictBomRefsInput); + result = FlatMerge(result, bom, quickStrategy); + countBoms++; + } + + // The quickly-made merged Bom is likely messy (only deduplicating + // identical entries). Run another merge, careful this time, over + // the resulting collection with a lot fewer items to inspect with + // the heavier logic. + var resultSubj = new Bom(); + // New merged document has its own identity (new SerialNumber, + // Version=1, Timestamp...) and its Tools collection refers to this + // library and the tool like cyclonedx-cli which consumes it. + resultSubj.BomMetadataUpdate(true); + resultSubj.BomMetadataReferThisToolkit(); + + if (bomSubject is null) + { + result = FlatMerge(resultSubj, result, safeStrategy); + } + else + { + // use the params provided if possible: prepare a new document + // with desired "metadata/component" and merge differing data + // from earlier collected result into this structure. + resultSubj.Metadata.Component = bomSubject; + resultSubj.Metadata.Component.BomRef = ComponentBomRefNamespace(result.Metadata.Component); + CountBomRefs(resultSubj, ref dictBomRefsInput); + result = FlatMerge(resultSubj, result, safeStrategy); + + var mainDependency = new Dependency(); + mainDependency.Ref = result.Metadata.Component.BomRef; + mainDependency.Dependencies = new List(); + + // Revisit original Boms which had a metadata/component + // to write them up as dependencies of newly injected + // top-level product name. + foreach (var bom in boms) + { + if (!(bom.Metadata?.Component is null)) + { + var dep = new Dependency(); + dep.Ref = bom.Metadata.Component.BomRef; + + mainDependency.Dependencies.Add(dep); + } + } + + result.Dependencies.Add(mainDependency); + } + + result = CleanupMetadataComponent(result); + result = CleanupEmptyLists(result); + result = CleanupSortLists(result); + + // Final sanity-check: + Dictionary dictBomRefsResult = CountBomRefs(result); + if (!Enumerable.SequenceEqual(dictBomRefsResult.Keys.OrderBy(e => e), dictBomRefsInput.Keys.OrderBy(e => e))) + { + Console.WriteLine("WARNING: Different sets of 'bom-ref' in the resulting document vs. original input files!"); + } + + return result; + } + + /// + /// Performs a hierarchical merge for multiple BOMs. + /// + /// To retain system component hierarchy, top level BOM metadata + /// component must be included in each BOM. + /// + /// + /// + /// The component described by the hierarchical merge being performed. + /// + /// This will be included as the top level BOM metadata component in + /// the returned BOM. + /// + /// + public static Bom HierarchicalMerge(IEnumerable boms, Component bomSubject) + { + var result = new Bom(); + // New resulting Bom has its own identity (timestamp, serial) + // and its Tools collection refers to this library and the + // tool which consumes it. + result.BomMetadataUpdate(true); + result.BomMetadataReferThisToolkit(); + + if (bomSubject != null) + { + if (bomSubject.BomRef is null) + { + bomSubject.BomRef = ComponentBomRefNamespace(bomSubject); + } + result.Metadata.Component = bomSubject; + } + + result.Components = new List(); + result.Services = new List(); + result.ExternalReferences = new List(); + result.Dependencies = new List(); + result.Compositions = new List(); + result.Vulnerabilities = new List(); + + var bomSubjectDependencies = new List(); + + foreach (var bom in boms) + { + if (bom.Metadata?.Component is null) + { + throw new MissingMetadataComponentException( + bom.SerialNumber is null + ? "Required metadata (top level) component is missing from BOM." + : $"Required metadata (top level) component is missing from BOM {bom.SerialNumber}."); + } + + if (bom.Metadata?.Tools?.Tools?.Count > 0) + { + result.Metadata.Tools.Tools.AddRange(bom.Metadata.Tools.Tools); + } + + var thisComponent = bom.Metadata.Component; + if (thisComponent.Components is null) bom.Metadata.Component.Components = new List(); + if (!(bom.Components is null)) + { + thisComponent.Components.AddRange(bom.Components); + } + + // add a namespace to existing BOM refs + NamespaceComponentBomRefs(thisComponent); + + // make sure we have a BOM ref set and add top level dependency reference + if (thisComponent.BomRef is null) thisComponent.BomRef = ComponentBomRefNamespace(thisComponent); + bomSubjectDependencies.Add(new Dependency { Ref = thisComponent.BomRef }); + + result.Components.Add(thisComponent); + + // services + if (bom.Services != null) + foreach (var service in bom.Services) + { + service.BomRef = NamespacedBomRef(bom.Metadata.Component, service.BomRef); + result.Services.Add(service); + } + + // external references + if (!(bom.ExternalReferences is null)) result.ExternalReferences.AddRange(bom.ExternalReferences); + + // dependencies + if (bom.Dependencies != null) + { + NamespaceDependencyBomRefs(ComponentBomRefNamespace(thisComponent), bom.Dependencies); + result.Dependencies.AddRange(bom.Dependencies); + } + + // compositions + if (bom.Compositions != null) + { + NamespaceCompositions(ComponentBomRefNamespace(bom.Metadata.Component), bom.Compositions); + result.Compositions.AddRange(bom.Compositions); + } + + // vulnerabilities + if (bom.Vulnerabilities != null) + { + NamespaceVulnerabilitiesRefs(ComponentBomRefNamespace(result.Metadata.Component), bom.Vulnerabilities); + result.Vulnerabilities.AddRange(bom.Vulnerabilities); + } + } + + if (bomSubject != null) + { + result.Dependencies.Add( new Dependency + { + Ref = result.Metadata.Component.BomRef, + Dependencies = bomSubjectDependencies + }); + } + + result = CleanupMetadataComponent(result); + result = CleanupEmptyLists(result); + + return result; + } + + /// + /// Merge main "metadata/component" entry with its possible alter-ego + /// in the components list and evict extra copy from that list: per + /// spec v1_4 at least, the bom-ref must be unique across the document. + /// + /// A Bom document + /// Resulting document (whether modified or not) + public static Bom CleanupMetadataComponent(Bom result) + { + if (!int.TryParse(System.Environment.GetEnvironmentVariable("CYCLONEDX_DEBUG_MERGE"), out int iDebugLevel) || iDebugLevel < 0) + { + iDebugLevel = 0; + } + + if (iDebugLevel >= 1) + { + Console.WriteLine($"MERGE-CLEANUP: metadata/component/bom-ref='{result.Metadata?.Component?.BomRef}'"); + } + + if (!(result.Metadata.Component is null) && !(result.Components is null) && (result.Components?.Count > 0) && result.Components.Contains(result.Metadata.Component)) + { + BomEntityListMergeHelperStrategy safeStrategy = BomEntityListMergeHelperStrategy.Default(); + if (iDebugLevel >= 2) + { + Console.WriteLine($"MERGE-CLEANUP: Searching in list"); + } + foreach (Component component in result.Components) + { + if (iDebugLevel >= 2) + { + Console.WriteLine($"MERGE-CLEANUP: Looking at a bom-ref='{component?.BomRef}'"); + } + if (component is null) + { + // should not happen, but... + continue; + } + if (component.Equals(result.Components) || component.BomRef.Equals(result.Metadata.Component.BomRef)) + { + if (iDebugLevel >= 1) + { + Console.WriteLine($"MERGE-CLEANUP: Found in list: merging, cleaning..."); + } + result.Metadata.Component.MergeWith(component, safeStrategy); + result.Components.Remove(component); + return result; + } + } + } + + if (iDebugLevel >= 1) + { + Console.WriteLine($"MERGE-CLEANUP: NO HITS"); + } + return result; + } + + /// + /// Clean up empty top level elements. + /// + /// A Bom document + /// Resulting document (whether modified or not) + public static Bom CleanupEmptyLists(Bom result) + { + if (result.Metadata?.Tools?.Tools?.Count == 0) + { + result.Metadata.Tools.Tools = null; + } + + if (result.Components?.Count == 0) + { + result.Components = null; + } + + if (result.Services?.Count == 0) + { + result.Services = null; + } + + if (result.ExternalReferences?.Count == 0) + { + result.ExternalReferences = null; + } + + if (result.Dependencies?.Count == 0) + { + result.Dependencies = null; + } + + if (result.Compositions?.Count == 0) + { + result.Compositions = null; + } + + if (result.Vulnerabilities?.Count == 0) + { + result.Vulnerabilities = null; + } + + return result; + } + + /// + /// Sort (top-level) list entries in the Bom for easier comparisons + /// and better compression.
+ /// TODO? Drill into the BomEntities to sort lists inside too? + ///
+ /// A Bom document + /// Resulting document (whether modified or not) + public static Bom CleanupSortLists(Bom result) + { + // Why oh why?.. error CS1503: Argument 1: cannot convert + // from 'System.Collections.Generic.List' + // to 'System.Collections.Generic.List' + // BomEntity.NormalizeList(result.Tools.Tools) -- it looks so simple! + // But at least we *can* call it, perhaps inefficiently for + // the run-time code and scaffolding, but easy to maintain + // with filter definitions now stored in classes, not here... + if (result.Metadata?.Tools?.Tools?.Count > 0) + { + #pragma warning disable 618 + var sortHelper = new ListMergeHelper(); + #pragma warning restore 618 + sortHelper.SortByAscending(result.Metadata.Tools.Tools, true); + } + + if (result.Components?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.Components, true); + } + + if (result.Services?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.Services, true); + } + + if (result.ExternalReferences?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.ExternalReferences, true); + } + + if (result.Dependencies?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.Dependencies, true); + } + + if (result.Compositions?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.Compositions, true); + } + + if (result.Vulnerabilities?.Count > 0) + { + var sortHelper = new ListMergeHelper(); + sortHelper.SortByAscending(result.Vulnerabilities, true); + } + + return result; + } + + // Currently our MergeWith() logic has potential to mess with + // Component bom entities (later maybe more types), and generally + // the document-wide uniqueness of BomRefs is a sore point, so + // we want them all accounted "before and after" the (flat) merge. + // Code below reuses the same dictionary object as initialized + // once for the Bom document's caller, to go faster about it: + private static void BumpDictCounter(T key, ref Dictionary dict) { + if (dict.ContainsKey(key)) { + dict[key]++; + return; + } + dict[key] = 1; + } + + private static void CountBomRefs(Component obj, ref Dictionary dict) { + if (obj is null) + { + return; + } + + if (obj.BomRef != null) + { + BumpDictCounter(obj.BomRef, ref dict); + } + + if (obj.Components != null && obj.Components.Count > 0) + { + foreach (Component child in obj.Components) + { + CountBomRefs(child, ref dict); + } + } + + if (obj.Pedigree != null) + { + if (obj.Pedigree.Ancestors != null && obj.Pedigree.Ancestors.Count > 0) + { + foreach (Component child in obj.Pedigree.Ancestors) + { + CountBomRefs(child, ref dict); + } + } + + if (obj.Pedigree.Descendants != null && obj.Pedigree.Descendants.Count > 0) + { + foreach (Component child in obj.Pedigree.Descendants) + { + CountBomRefs(child, ref dict); + } + } + + if (obj.Pedigree.Variants != null && obj.Pedigree.Variants.Count > 0) + { + foreach (Component child in obj.Pedigree.Variants) + { + CountBomRefs(child, ref dict); + } + } + } + } + + private static void CountBomRefs(Service obj, ref Dictionary dict) { + if (obj is null) + { + return; + } + + if (obj.BomRef != null) + { + BumpDictCounter(obj.BomRef, ref dict); + } + + if (obj.Services != null && obj.Services.Count > 0) + { + foreach (Service child in obj.Services) + { + CountBomRefs(child, ref dict); + } + } + } + + private static void CountBomRefs(Vulnerability obj, ref Dictionary dict) { + if (obj is null) + { + return; + } + + if (obj.BomRef != null) + { + BumpDictCounter(obj.BomRef, ref dict); + } + + // Note: Vulnerability objects are not nested (as of CDX 1.4) + } + + private static void CountBomRefs(Bom bom, ref Dictionary dict) { + if (bom is null) + { + return; + } + + if (bom.Metadata?.Component != null) { + CountBomRefs(bom.Metadata.Component, ref dict); + } + + if (bom.Components != null && bom.Components.Count > 0) + { + foreach (Component child in bom.Components) + { + CountBomRefs(child, ref dict); + } + } + + if (bom.Services != null && bom.Services.Count > 0) + { + foreach (Service child in bom.Services) + { + CountBomRefs(child, ref dict); + } + } + + if (bom.Vulnerabilities != null && bom.Vulnerabilities.Count > 0) + { + foreach (Vulnerability child in bom.Vulnerabilities) + { + CountBomRefs(child, ref dict); + } + } + } + + private static Dictionary CountBomRefs(Bom bom) { + var dict = new Dictionary(); + CountBomRefs(bom, ref dict); + return dict; + } + + private static string NamespacedBomRef(Component bomSubject, string bomRef) + { + return string.IsNullOrEmpty(bomRef) ? null : NamespacedBomRef(ComponentBomRefNamespace(bomSubject), bomRef); + } + + private static string NamespacedBomRef(string bomRefNamespace, string bomRef) + { + return string.IsNullOrEmpty(bomRef) ? null : $"{bomRefNamespace}:{bomRef}"; + } + + private static string ComponentBomRefNamespace(Component component) + { + return component.Group is null + ? $"{component.Name}@{component.Version}" + : $"{component.Group}.{component.Name}@{component.Version}"; + } + + private static void NamespaceComponentBomRefs(Component topComponent) + { + var components = new Stack(); + components.Push(topComponent); + + while (components.Count > 0) + { + var currentComponent = components.Pop(); + + if (currentComponent.Components != null) + { + foreach (var subComponent in currentComponent.Components) + { + components.Push(subComponent); + } + } + + currentComponent.BomRef = NamespacedBomRef(topComponent, currentComponent.BomRef); + } + } + + private static void NamespaceVulnerabilitiesRefs(string bomRefNamespace, List vulnerabilities) + { + var pendingVulnerabilities = new Stack(vulnerabilities); + + while (pendingVulnerabilities.Count > 0) + { + var vulnerability = pendingVulnerabilities.Pop(); + + vulnerability.BomRef = NamespacedBomRef(bomRefNamespace, vulnerability.BomRef); + + if (vulnerability.Affects != null) + { + foreach (var affect in vulnerability.Affects) + { + affect.Ref = bomRefNamespace; + } + } + } + } + + private static void NamespaceDependencyBomRefs(string bomRefNamespace, List dependencies) + { + var pendingDependencies = new Stack(dependencies); + + while (pendingDependencies.Count > 0) + { + var dependency = pendingDependencies.Pop(); + + if (dependency.Dependencies != null) + { + foreach (var subDependency in dependency.Dependencies) + { + pendingDependencies.Push(subDependency); + } + } + + dependency.Ref = NamespacedBomRef(bomRefNamespace, dependency.Ref); + } + } + + private static void NamespaceCompositions(string bomRefNamespace, List compositions) + { + foreach (var composition in compositions) + { + if (composition.Assemblies != null) + { + for (var i=0; i