diff --git a/HISTORY.md b/HISTORY.md index ae2c52fa..a952e692 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -50,6 +50,7 @@ All notable changes to this project will be documented in this file. and changed parameter & return type to non-nullable, was nullable ([#66] via [#131]) * BREAKING: renamed methods `{get,set}MetaData()` -> `{get,set}Metadata()` ([#133] via [#131]) and changed parameter & return type to non-nullable, was nullable ([#66] via [#131]) + * Added `{get,set}SerialNumber()` (via [#186]) * `Component` class * BREAKING: renamed methods `{get,set}DependenciesBomRefRepository()` -> `{get,set}Dependencies()` ([#133] via [#131]) and changed parameter & return type to non-nullable, was nullable ([#66] via [#131]) @@ -61,7 +62,7 @@ All notable changes to this project will be documented in this file. and changed it work with class `LicenseRepository` only, was working with various `Models\License\*` types. ([#66] via [#131]) * BREAKING: Changed class property `version` to optional now, to reflect CycloneDX v1.4. ([#27] via [#118], [#131]) This affects constructor arguments, and affects methods `{get,set}Version()`. - * Added `{get,set}Author()` ([#184] via[#185]) + * Added `{get,set}Author()` ([#184] via [#185]) * Added `{get,set}Properties()` (via [#165]) * `ExternalReference` class * BREAKING: renamed methods `{get,set}HashRepository()` -> `{get,set}Hashes()` ([#133] via [#131]) @@ -185,6 +186,7 @@ All notable changes to this project will be documented in this file. [#180]: https://github.com/CycloneDX/cyclonedx-php-library/pull/180 [#181]: https://github.com/CycloneDX/cyclonedx-php-library/pull/181 [#185]: https://github.com/CycloneDX/cyclonedx-php-library/pull/185 +[#186]: https://github.com/CycloneDX/cyclonedx-php-library/pull/186 ## 1.6.3 - 2022-09-15 diff --git a/src/Core/Models/Bom.php b/src/Core/Models/Bom.php index 2a89df7e..f188eaef 100644 --- a/src/Core/Models/Bom.php +++ b/src/Core/Models/Bom.php @@ -33,6 +33,21 @@ */ class Bom { + // Property `bomFormat` is not part of model, it is runtime information. + + // Property `specVersion` is not part of model, it is runtime information. + + /** + * Every BOM generated SHOULD have a unique serial number, even if the contents of the BOM have not changed over time. + * If specified, the serial number MUST conform to RFC-4122. + * Use of serial numbers are RECOMMENDED. + * + * pattern: ^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ + * + * @psalm-var non-empty-string|null + */ + private ?string $serialNumber = null; + /** * @psalm-suppress PropertyNotSetInConstructor */ @@ -56,6 +71,9 @@ class Bom */ private ExternalReferenceRepository $externalReferences; + // Property `dependencies` is not part of this model, but part of `Component` and other models. + // The dependency graph can be normalized on render-time, no need to store it in the bom model. + public function __construct(?ComponentRepository $components = null) { $this->setComponents($components ?? new ComponentRepository()); @@ -63,6 +81,42 @@ public function __construct(?ComponentRepository $components = null) $this->metadata = new Metadata(); } + /** + * @psalm-return non-empty-string|null + */ + public function getSerialNumber(): ?string + { + return $this->serialNumber; + } + + /** + * @param string|null $serialNumber an empty value or a valid urn:uuid + * + * @throws DomainException if version is neither empty nor a valid urn:uuid + * + * @return $this + */ + public function setSerialNumber(?string $serialNumber): self + { + if ('' === $serialNumber) { + $serialNumber = null; + } + if (null !== $serialNumber && !self::isValidSerialNumber($serialNumber)) { + throw new DomainException("Invalid value: $serialNumber"); + } + $this->serialNumber = $serialNumber; + + return $this; + } + + private static function isValidSerialNumber(string $serialNumber): bool + { + return 1 === preg_match( + '/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', + $serialNumber + ); + } + public function getComponents(): ComponentRepository { return $this->components; diff --git a/src/Core/Serialization/DOM/Normalizers/BomNormalizer.php b/src/Core/Serialization/DOM/Normalizers/BomNormalizer.php index 2b422bc9..9b84d151 100644 --- a/src/Core/Serialization/DOM/Normalizers/BomNormalizer.php +++ b/src/Core/Serialization/DOM/Normalizers/BomNormalizer.php @@ -52,7 +52,7 @@ public function normalize(Bom $bom): DOMElement $element, [ 'version' => $bom->getVersion(), - // serialNumber + 'serialNumber' => $bom->getSerialNumber(), ] ); diff --git a/src/Core/Serialization/JSON/Normalizers/BomNormalizer.php b/src/Core/Serialization/JSON/Normalizers/BomNormalizer.php index fc378c78..e04f8454 100644 --- a/src/Core/Serialization/JSON/Normalizers/BomNormalizer.php +++ b/src/Core/Serialization/JSON/Normalizers/BomNormalizer.php @@ -54,6 +54,7 @@ public function normalize(Bom $bom): array '$schema' => self::SCHEMA[$factory->getSpec()->getVersion()] ?? null, 'bomFormat' => self::BOM_FORMAT, 'specVersion' => $factory->getSpec()->getVersion(), + 'serialNumber' => $bom->getSerialNumber(), 'version' => $bom->getVersion(), 'metadata' => $this->normalizeMetadata($bom->getMetadata()), 'components' => $factory->makeForComponentRepository()->normalize($bom->getComponents()), diff --git a/tests/Core/Models/BomTest.php b/tests/Core/Models/BomTest.php index d7366450..a4be4128 100644 --- a/tests/Core/Models/BomTest.php +++ b/tests/Core/Models/BomTest.php @@ -49,6 +49,7 @@ public function testConstruct(): Bom $bom = new Bom($components); + self::assertNull($bom->getSerialNumber()); self::assertSame(1, $bom->getVersion()); self::assertSame($components, $bom->getComponents()); self::assertCount(0, $bom->getExternalReferences()); @@ -57,6 +58,45 @@ public function testConstruct(): Bom return $bom; } + // region serialNumber setter&getter + + /** + * @depends testConstruct + */ + public function testSerialNumber(Bom $bom): Bom + { + $serialNumber = 'urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79'; + $setOn = $bom->setSerialNumber($serialNumber); + + self::assertSame($bom, $setOn); + self::assertSame($serialNumber, $bom->getSerialNumber()); + + return $bom; + } + + /** + * @depends testSerialNumber + */ + public function testSerialNumberEmptyString(Bom $bom): void + { + $setOn = $bom->setSerialNumber(''); + + self::assertSame($bom, $setOn); + self::assertNull($bom->getSerialNumber()); + } + + /** + * @depends testSerialNumber + */ + public function testSerialNumberEmptyStringInvalidValue(Bom $bom): void + { + $serialNumber = uniqid('invalid-value', true); + $this->expectException(DomainException::class); + $bom->setSerialNumber($serialNumber); + } + + // endregion serialNumber setter&getter + // region components setter&getter&modifiers /** diff --git a/tests/Core/Serialization/DOM/Normalizers/BomNormalizerTest.php b/tests/Core/Serialization/DOM/Normalizers/BomNormalizerTest.php index ac95bdd1..277c720f 100644 --- a/tests/Core/Serialization/DOM/Normalizers/BomNormalizerTest.php +++ b/tests/Core/Serialization/DOM/Normalizers/BomNormalizerTest.php @@ -59,6 +59,7 @@ public function testNormalize(): void $bom = $this->createConfiguredMock( Bom::class, [ + 'getSerialNumber' => 'urn:uuid:12345678-dead-1337-beef-123456789012', 'getVersion' => 23, ] ); @@ -66,7 +67,7 @@ public function testNormalize(): void $actual = $normalizer->normalize($bom); self::assertStringEqualsDomNode( - ''. + ''. ''. '', $actual @@ -89,7 +90,7 @@ public function testNormalizeComponents(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 42, 'getComponents' => $this->createStub(ComponentRepository::class), ] ); @@ -102,7 +103,7 @@ public function testNormalizeComponents(): void $actual = $normalizer->normalize($bom); self::assertStringEqualsDomNode( - ''. + ''. 'dummy'. '', $actual @@ -133,7 +134,7 @@ public function testNormalizeMetadata(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1337, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -146,7 +147,7 @@ public function testNormalizeMetadata(): void $actual = $normalizer->normalize($bom); self::assertStringEqualsDomNode( - ''. + ''. 'FakeMetadata'. ''. '', @@ -176,7 +177,7 @@ public function testNormalizeMetadataNotSupported(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -188,7 +189,7 @@ public function testNormalizeMetadataNotSupported(): void $actual = $normalizer->normalize($bom); self::assertStringEqualsDomNode( - ''. + ''. ''. '', $actual diff --git a/tests/Core/Serialization/JSON/Normalizers/BomNormalizerTest.php b/tests/Core/Serialization/JSON/Normalizers/BomNormalizerTest.php index d19a69a1..c57bd3b5 100644 --- a/tests/Core/Serialization/JSON/Normalizers/BomNormalizerTest.php +++ b/tests/Core/Serialization/JSON/Normalizers/BomNormalizerTest.php @@ -56,6 +56,7 @@ public function testNormalize(): void $bom = $this->createConfiguredMock( Bom::class, [ + 'getSerialNumber' => 'urn:uuid:12345678-dead-1337-beef-123456789012', 'getVersion' => 23, ] ); @@ -67,6 +68,7 @@ public function testNormalize(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', + 'serialNumber' => 'urn:uuid:12345678-dead-1337-beef-123456789012', 'version' => 23, 'components' => [], ], @@ -89,7 +91,7 @@ public function testNormalizeComponents(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 42, 'getComponents' => $this->createStub(ComponentRepository::class), ] ); @@ -106,7 +108,7 @@ public function testNormalizeComponents(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 42, 'components' => ['FakeComponents'], ], $actual @@ -139,7 +141,7 @@ public function testNormalizeMetadata(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1337, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -156,7 +158,7 @@ public function testNormalizeMetadata(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 1337, 'metadata' => ['FakeMetadata'], 'components' => [], ], @@ -185,7 +187,7 @@ public function testNormalizeMetadataEmpty(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -201,7 +203,7 @@ public function testNormalizeMetadataEmpty(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 1, 'components' => [], ], $actual @@ -229,7 +231,7 @@ public function testNormalizeMetadataSkipped(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -245,7 +247,7 @@ public function testNormalizeMetadataSkipped(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 1, 'components' => [], ], $actual @@ -277,7 +279,7 @@ public function testNormalizeDependencies(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -294,7 +296,7 @@ public function testNormalizeDependencies(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 1, 'components' => [], 'dependencies' => ['FakeDependencies' => 'dummy'], ], @@ -323,7 +325,7 @@ public function testNormalizeDependenciesOmitWhenEmpty(): void $bom = $this->createConfiguredMock( Bom::class, [ - 'getVersion' => 23, + 'getVersion' => 1, 'getMetadata' => $this->createStub(Metadata::class), ] ); @@ -340,7 +342,7 @@ public function testNormalizeDependenciesOmitWhenEmpty(): void '$schema' => 'http://cyclonedx.org/schema/bom-1.2b.schema.json', 'bomFormat' => 'CycloneDX', 'specVersion' => '1.2', - 'version' => 23, + 'version' => 1, 'components' => [], // 'dependencies' is unset, ], diff --git a/tests/_data/BomModelProvider.php b/tests/_data/BomModelProvider.php index b867ca3f..dd22e5bd 100644 --- a/tests/_data/BomModelProvider.php +++ b/tests/_data/BomModelProvider.php @@ -61,11 +61,8 @@ abstract class BomModelProvider public static function allBomTestData(): Generator { yield from self::bomPlain(); - yield from self::bomWithAllComponents(); - yield from self::bomWithAllMetadata(); - yield from self::bomWithExternalReferences(); } @@ -81,7 +78,8 @@ public static function allBomTestData(): Generator public static function bomPlain(): Generator { yield 'bom plain' => [new Bom()]; - yield 'bom plain v23' => [(new Bom())->setVersion(23)]; + yield 'bom plain with version' => [(new Bom())->setVersion(23)]; + yield 'bom plain with serialNumber' => [(new Bom())->setSerialNumber('urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79')]; } /**