diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 7896f46da..047ddf105 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -245,9 +245,7 @@ jobs: name: dist.node path: dist.node - name: test - run: > - npm run -- test:node - ${{ startsWith(matrix.node-version, env.NODE_ACTIVE_LTS) && ' --forbid-pending' || '' }} + run: npm run -- test:node - name: collect coverage if: ${{ failure() || success() }} run: > diff --git a/HISTORY.md b/HISTORY.md index 61b85a220..501f206a0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file. +* Added + * Optional-feature classes expose a static `cannot` property (via [#1534]) + It tells whether missing optional dependencies prevent the class from acting: + `false` when the feature is available; + otherwise an object with a `message` describing why the feature is unavailable. + Affected classes: + * `Serialize.JsonSerializer` — always `false` + * `Serialize.XmlSerializer` — always `false` in web builds; in node it depends on an optional XML library + * `Validation.JsonValidator` + * `Validation.JsonStrictValidator` + * `Validation.XmlValidator` +* Changed + * `Serialize.XmlSerializer`, `Validation.JsonValidator`, `Validation.JsonStrictValidator` and `Validation.XmlValidator` + now throw `MissingOptionalDependencyError` based on the new `cannot` check, + instead of wrapping the underlying `OptPlugError` (via [#1534]) + +[#1534]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1534 + ## 10.3.0 -- 2026-09-17 * Added diff --git a/src/_opt.ts b/src/_opt.ts new file mode 100644 index 000000000..84957f1bd --- /dev/null +++ b/src/_opt.ts @@ -0,0 +1,40 @@ +/*! +This file is part of CycloneDX JavaScript Library. + +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. +*/ + +/** + * Describes why an optional feature cannot act. + */ +interface Blocker { + readonly message: string +} + + +/** + * `false` if the optional feature can act; + * otherwise, a {@link Blocker} describing why the feature is unavailable. + */ +export type Cannot = false | Readonly + +/** + * Contract for classes that provide optional features. + * The class itself (not the instance) reports its availability. + */ +export interface Provider { + readonly cannot: Cannot +} diff --git a/src/_optPlug.node/_wrapper.ts b/src/_optPlug.node/_wrapper.ts index b34359269..b380cb69b 100644 --- a/src/_optPlug.node/_wrapper.ts +++ b/src/_optPlug.node/_wrapper.ts @@ -19,20 +19,31 @@ Copyright (c) OWASP Foundation. All Rights Reserved. import { OptPlugError } from './errors' -export type WillThrow = (() => never) & { fails: true } -function makeWIllThrow (message: string): WillThrow { +export type WillThrow = (() => never) & { fails: true, message: string } + +function makeWillThrow (message: string): WillThrow { const f: WillThrow = function (): never { - throw new OptPlugError(message) + throw new OptPlugError(f.message) } f.fails = true + f.message = message return Object.freeze(f) } -type PossibleFunctionalities = Array<[string, () => Functionality]> +/** @internal */ +export function isWillThrow (v: any): v is WillThrow { + /* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ack */ + return typeof v === 'function' && (v as Partial).fails === true +} + + +type FunctionalityConstraint = CallableFunction & { fails?: false } + +type PossibleFunctionalities = Array<[string, () => Functionality]> /** @internal */ -export default function ( +export default function ( name: string, pf: PossibleFunctionalities ): Functionality | WillThrow { @@ -43,7 +54,7 @@ export default function ( /* pass */ } } - return makeWIllThrow( + return makeWillThrow( `No ${name} available.\n` + 'Please install one of the optional dependencies: ' + pf.map(kv => kv[0]).join(' || ') diff --git a/src/_optPlug.node/cannot.ts b/src/_optPlug.node/cannot.ts new file mode 100644 index 000000000..04251ded4 --- /dev/null +++ b/src/_optPlug.node/cannot.ts @@ -0,0 +1,32 @@ +/*! +This file is part of CycloneDX JavaScript Library. + +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. +*/ + +import type { Cannot } from '../_opt' +import { isWillThrow } from './_wrapper' + +/** + * Translate a pluggable functionality into an {@link Cannot}. + * + * @internal + */ +export default function cannotOf (f: unknown): Cannot { + return isWillThrow(f) + ? Object.freeze({ message: f.message }) + : false +} diff --git a/src/serialize/jsonSerializer.ts b/src/serialize/jsonSerializer.ts index f85948d04..27389fe66 100644 --- a/src/serialize/jsonSerializer.ts +++ b/src/serialize/jsonSerializer.ts @@ -17,6 +17,7 @@ SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ +import type * as OptionalFeature from '../_opt' import type { Bom } from '../models/bom' import { Format } from '../spec/enums' import { UnsupportedFormatError } from '../spec/errors' @@ -25,10 +26,19 @@ import type { Factory as NormalizerFactory } from './json/normalize' import type { Normalized } from './json/types' import type { NormalizerOptions, SerializerOptions } from './types' + /** * Multi purpose Json serializer. */ export class JsonSerializer extends BaseSerializer { + /** + * Indicates whether this functionality cannot act. + * + * Always `false` for JSON functionality: no optional dependencies are involved, + * the platform-native `JSON.stringify` is used. + */ + static readonly cannot: OptionalFeature.Cannot = false + readonly #normalizerFactory: NormalizerFactory /** @@ -62,3 +72,5 @@ export class JsonSerializer extends BaseSerializer { return JSON.stringify(bom, null, space) } } + +JsonSerializer satisfies OptionalFeature.Provider diff --git a/src/serialize/xmlSerializer.node.ts b/src/serialize/xmlSerializer.node.ts index bfc6ce056..11bf46a0d 100644 --- a/src/serialize/xmlSerializer.node.ts +++ b/src/serialize/xmlSerializer.node.ts @@ -17,18 +17,27 @@ SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ -import { OptPlugError } from '../_optPlug.node/errors' +import type * as OptionalFeature from '../_opt' +import cannotOf from '../_optPlug.node/cannot' import stringify from '../_optPlug.node/xmlStringify' import { MissingOptionalDependencyError } from './errors' import type { SerializerOptions } from './types' import type { SimpleXml } from './xml/types' import { XmlBaseSerializer } from './xmlBaseSerializer' + /** * XML serializer for node. */ export class XmlSerializer extends XmlBaseSerializer { - // maybe override parent::serialize() and skip nonmalization and everything, in case `stringify.fails` is true + + /** + * Indicates whether this functionality cannot act. + * + * `false` if this functionality can act; otherwise, describes which optional dependencies are + * unavailable and prevent it from doing so. + */ + static readonly cannot: OptionalFeature.Cannot = cannotOf(stringify) /** * @throws {@link Serialize.MissingOptionalDependencyError | MissingOptionalDependencyError} @@ -38,13 +47,9 @@ export class XmlSerializer extends XmlBaseSerializer { normalizedBom: SimpleXml.Element, options: SerializerOptions = {} ): string { - try { - return stringify(normalizedBom, options) - } catch (err) { - if (err instanceof OptPlugError) { - throw new MissingOptionalDependencyError(err.message, err) - } - throw err - } + if (XmlSerializer.cannot !== false) throw new MissingOptionalDependencyError(XmlSerializer.cannot.message) + return stringify(normalizedBom, options) } } + +XmlSerializer satisfies OptionalFeature.Provider diff --git a/src/serialize/xmlSerializer.web.ts b/src/serialize/xmlSerializer.web.ts index 2671f501e..b0f16bccf 100644 --- a/src/serialize/xmlSerializer.web.ts +++ b/src/serialize/xmlSerializer.web.ts @@ -18,6 +18,7 @@ Copyright (c) OWASP Foundation. All Rights Reserved. */ import { isNotUndefined } from '../_helpers/notUndefined' +import type * as OptionalFeature from '../_opt' import type { SerializerOptions } from './types' import type { SimpleXml } from './xml/types' import { XmlBaseSerializer } from './xmlBaseSerializer' @@ -26,6 +27,14 @@ import { XmlBaseSerializer } from './xmlBaseSerializer' * XML serializer for web browsers. */ export class XmlSerializer extends XmlBaseSerializer { + /** + * Indicates whether this functionality cannot act. + * + * Always `false` in web browsers: no optional dependencies are involved, + * the platform-native "XMLSerializer" is used. + */ + static readonly cannot: OptionalFeature.Cannot = false + protected _serialize ( normalizedBom: SimpleXml.Element, { space }: SerializerOptions = {} @@ -91,3 +100,5 @@ export class XmlSerializer extends XmlBaseSerializer { } } } + +XmlSerializer satisfies OptionalFeature.Provider diff --git a/src/validation/jsonValidator.node.ts b/src/validation/jsonValidator.node.ts index 486a8082e..157c585d5 100644 --- a/src/validation/jsonValidator.node.ts +++ b/src/validation/jsonValidator.node.ts @@ -17,14 +17,25 @@ SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ -import { OptPlugError } from '../_optPlug.node/errors' +import type * as OptionalFeature from '../_opt' +import cannotOf from '../_optPlug.node/cannot' import makeValidator, { type Validator } from '../_optPlug.node/jsonValidator' import { FILES } from '../resources.node' import { BaseValidator } from './baseValidator' import { MissingOptionalDependencyError, NotImplementedError } from './errors' import type { ValidationError } from './types' + abstract class BaseJsonValidator extends BaseValidator { + + /** + * Indicates whether this functionality cannot act. + * + * `false` if this functionality can act; otherwise, describes which optional dependencies are + * unavailable and prevent it from doing so. + */ + static readonly cannot: OptionalFeature.Cannot = cannotOf(makeValidator) + /** @internal */ protected abstract _getSchemaFile (): string | undefined @@ -40,18 +51,12 @@ abstract class BaseJsonValidator extends BaseValidator { async #getValidator (): Promise { if (this.#validatorCache === undefined) { - try { - this.#validatorCache = await makeValidator(this.#getSchemaFilePath(), { - 'http://cyclonedx.org/schema/spdx.SNAPSHOT.schema.json': FILES.SPDX.JSON_SCHEMA, - 'http://cyclonedx.org/schema/cryptography-defs.SNAPSHOT.schema.json': FILES.CryptoDefs.JSON_SCHEMA, - 'http://cyclonedx.org/schema/jsf-0.82.SNAPSHOT.schema.json': FILES.JSF.JSON_SCHEMA - }) - } catch (err) { - if (err instanceof OptPlugError) { - throw new MissingOptionalDependencyError(err.message, err) - } - throw err - } + if (BaseJsonValidator.cannot !== false) throw new MissingOptionalDependencyError(BaseJsonValidator.cannot.message) + this.#validatorCache = await makeValidator(this.#getSchemaFilePath(), { + 'http://cyclonedx.org/schema/spdx.SNAPSHOT.schema.json': FILES.SPDX.JSON_SCHEMA, + 'http://cyclonedx.org/schema/cryptography-defs.SNAPSHOT.schema.json': FILES.CryptoDefs.JSON_SCHEMA, + 'http://cyclonedx.org/schema/jsf-0.82.SNAPSHOT.schema.json': FILES.JSF.JSON_SCHEMA + }) } return this.#validatorCache } @@ -71,6 +76,9 @@ abstract class BaseJsonValidator extends BaseValidator { return (await this.#getValidator())(data) } } + +BaseJsonValidator satisfies OptionalFeature.Provider + export class JsonValidator extends BaseJsonValidator { /** @internal */ protected _getSchemaFile (): string | undefined { diff --git a/src/validation/xmlValidator.node.ts b/src/validation/xmlValidator.node.ts index e007ee991..9de2ef314 100644 --- a/src/validation/xmlValidator.node.ts +++ b/src/validation/xmlValidator.node.ts @@ -17,14 +17,25 @@ SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ -import { OptPlugError } from '../_optPlug.node/errors' +import type * as OptionalFeature from '../_opt' +import cannotOf from '../_optPlug.node/cannot' import makeValidator, { type Validator } from '../_optPlug.node/xmlValidator' import { FILES } from '../resources.node' import { BaseValidator } from './baseValidator' import { MissingOptionalDependencyError, NotImplementedError } from './errors' import type { ValidationError } from './types' + export class XmlValidator extends BaseValidator { + + /** + * Indicates whether this functionality cannot act. + * + * `false` if this functionality can act; otherwise, describes which optional dependencies are + * unavailable and prevent it from doing so. + */ + static readonly cannot: OptionalFeature.Cannot = cannotOf(makeValidator) + #getSchemaFilePath (): string { const s = FILES.CDX.XML_SCHEMA[this.version] /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- better be safe */ @@ -38,14 +49,8 @@ export class XmlValidator extends BaseValidator { async #getValidator (): Promise { if (this.#validatorCache === undefined) { - try { - this.#validatorCache = await makeValidator(this.#getSchemaFilePath()) - } catch (err) { - if (err instanceof OptPlugError) { - throw new MissingOptionalDependencyError(err.message, err) - } - throw err - } + if (XmlValidator.cannot !== false) throw new MissingOptionalDependencyError(XmlValidator.cannot.message) + this.#validatorCache = await makeValidator(this.#getSchemaFilePath()) } return this.#validatorCache } @@ -65,3 +70,5 @@ export class XmlValidator extends BaseValidator { return (await this.#getValidator())(data) } } + +XmlValidator satisfies OptionalFeature.Provider diff --git a/tests/functional/Validation.JsonValidator.node.spec.js b/tests/functional/Validation.JsonValidator.node.spec.js index 4122e49ac..aa0337ed4 100644 --- a/tests/functional/Validation.JsonValidator.node.spec.js +++ b/tests/functional/Validation.JsonValidator.node.spec.js @@ -30,8 +30,7 @@ const { } = require('../../') before(function () { - const { default: jsonValidator } = require('../../dist.node/_optPlug.node/jsonValidator') - if (jsonValidator.fails) { + if (JsonValidator.cannot !== false) { this.skip() } }) diff --git a/tests/functional/Validation.XmlValidator.node.spec.js b/tests/functional/Validation.XmlValidator.node.spec.js index 6e477b55f..f3ff4d59e 100644 --- a/tests/functional/Validation.XmlValidator.node.spec.js +++ b/tests/functional/Validation.XmlValidator.node.spec.js @@ -30,8 +30,7 @@ const { } = require('../../') before(function () { - const { default: xmlValidator } = require('../../dist.node/_optPlug.node/xmlValidator') - if (xmlValidator.fails) { + if (XmlValidator.cannot !== false) { this.skip() } }) diff --git a/tests/integration/Serialize.XmlSerialize.test.js b/tests/integration/Serialize.XmlSerialize.test.js index 562727a6e..c87fcba80 100644 --- a/tests/integration/Serialize.XmlSerialize.test.js +++ b/tests/integration/Serialize.XmlSerialize.test.js @@ -19,7 +19,7 @@ Copyright (c) OWASP Foundation. All Rights Reserved. const assert = require('node:assert') -const { afterEach, beforeEach, describe, it } = require('mocha') +const { afterEach, before, beforeEach, describe, it } = require('mocha') const { Models, Enums, @@ -30,12 +30,15 @@ const { Spec, Validation } = require('../../') -const { default: xmlStringify } = require('../../dist.node/_optPlug.node/xmlStringify') const { createAllTools, createComplexStructure } = require('../_data/models') const { loadSerializeResult, writeSerializeResult } = require('../_data/serialize') describe('integration.Serialize.XmlSerialize', function () { - const expectMissingDepError = xmlStringify.fails ?? false + before(function () { + if (XmlSerializer.cannot !== false) { + this.skip() + } + }) this.timeout(60000) @@ -61,17 +64,6 @@ describe('integration.Serialize.XmlSerialize', function () { delete this.bom }) - if (expectMissingDepError) { - it('throws MissingOptionalDependencyError', function () { - const serializer = new XmlSerializer(normalizerFactory) - assert.throws( - () => { serializer.serialize(this.bom, {}) }, - (err) => err instanceof MissingOptionalDependencyError - ) - }) - return // skip other tests - } - it('serialize', async function () { const serializer = new XmlSerializer(normalizerFactory) const serialized = await serializer.serialize( @@ -160,3 +152,21 @@ describe('integration.Serialize.XmlSerialize', function () { }) }) }) + +describe('integration.Serialize.XmlSerialize cannot', function () { + before(function () { + if (XmlSerializer.cannot === false) { + this.skip() + } + }) + + it('deps missing throws MissingOptionalDependencyError', function () { + const normalizerFactory = new XmlNormalizeFactory(Spec.Spec1dot7) + const serializer = new XmlSerializer(normalizerFactory) + const bom = createComplexStructure() + assert.throws( + () => { serializer.serialize(bom, {}) }, + (err) => err instanceof MissingOptionalDependencyError && err.message === XmlSerializer.cannot.message + ) + }) +}) diff --git a/tests/integration/Validation.JsonStrictValidator.test.js b/tests/integration/Validation.JsonStrictValidator.test.js index 0dd80a394..e952a54a4 100644 --- a/tests/integration/Validation.JsonStrictValidator.test.js +++ b/tests/integration/Validation.JsonStrictValidator.test.js @@ -19,7 +19,7 @@ Copyright (c) OWASP Foundation. All Rights Reserved. const assert = require('node:assert') -const { describe, it } = require('mocha') +const { before, describe, it } = require('mocha') const { Spec: { Version }, @@ -28,10 +28,13 @@ const { JsonStrictValidator } } = require('../../') -const { default: jsonValidator } = require('../../dist.node/_optPlug.node/jsonValidator') describe('integration.Validation.JsonStrictValidator', () => { - const expectMissingDepError = jsonValidator.fails ?? false; + before(function () { + if (JsonStrictValidator.cannot !== false) { + this.skip() + } + }); [ Version.v1dot0, @@ -54,17 +57,6 @@ describe('integration.Validation.JsonStrictValidator', () => { Version.v1dot3, Version.v1dot2 ].forEach((version) => describe(version, () => { - if (expectMissingDepError) { - it('throws MissingOptionalDependencyError', async () => { - const validator = new JsonStrictValidator(version) - await assert.rejects( - validator.validate('{}'), - (err) => err instanceof MissingOptionalDependencyError - ) - }) - return // skip other tests - } - it('invalid throws', async () => { const validator = new JsonStrictValidator(version) const input = JSON.stringify({ @@ -97,3 +89,19 @@ describe('integration.Validation.JsonStrictValidator', () => { }) })) }) + +describe('integration.Validation.JsonStrictValidator cannot', () => { + before(function () { + if (JsonStrictValidator.cannot === false) { + this.skip() + } + }) + + it('deps missing throws MissingOptionalDependencyError', async () => { + const validator = new JsonStrictValidator(Version.v1dot7) + await assert.rejects( + validator.validate('{}'), + (err) => err instanceof MissingOptionalDependencyError && err.message === JsonStrictValidator.cannot.message + ) + }) +}) diff --git a/tests/integration/Validation.JsonValidator.test.js b/tests/integration/Validation.JsonValidator.test.js index 124db5b0f..2de450660 100644 --- a/tests/integration/Validation.JsonValidator.test.js +++ b/tests/integration/Validation.JsonValidator.test.js @@ -19,7 +19,7 @@ Copyright (c) OWASP Foundation. All Rights Reserved. const assert = require('node:assert') -const { describe, it } = require('mocha') +const { before, describe, it } = require('mocha') const { Spec: { Version }, @@ -28,13 +28,16 @@ const { JsonValidator } } = require('../../') -const { default: jsonValidator } = require('../../dist.node/_optPlug.node/jsonValidator') describe('integration.Validation.JsonValidator', () => { - const expectMissingDepError = jsonValidator.fails ?? false; + before(function () { + if (JsonValidator.cannot !== false) { + this.skip() + } + }); [ - 'somthing-unexpected', + 'something-unexpected', Version.v1dot0, Version.v1dot1 ].forEach((version) => describe(version, () => { @@ -55,17 +58,6 @@ describe('integration.Validation.JsonValidator', () => { Version.v1dot3, Version.v1dot2 ].forEach((version) => describe(version, () => { - if (expectMissingDepError) { - it('throws MissingOptionalDependencyError', async () => { - const validator = new JsonValidator(version) - await assert.rejects( - validator.validate('{}'), - (err) => err instanceof MissingOptionalDependencyError - ) - }) - return // skip other tests - } - it('invalid throws', async () => { const validator = new JsonValidator(version) const input = JSON.stringify({ @@ -98,3 +90,19 @@ describe('integration.Validation.JsonValidator', () => { }) })) }) + +describe('integration.Validation.JsonValidator cannot', () => { + before(function () { + if (JsonValidator.cannot === false) { + this.skip() + } + }) + + it('deps missing throws MissingOptionalDependencyError', async () => { + const validator = new JsonValidator(Version.v1dot7) + await assert.rejects( + validator.validate('{}'), + (err) => err instanceof MissingOptionalDependencyError && err.message === JsonValidator.cannot.message + ) + }) +}) diff --git a/tests/integration/Validation.XmlValidator.test.js b/tests/integration/Validation.XmlValidator.test.js index f172fbc59..c5e9c38cf 100644 --- a/tests/integration/Validation.XmlValidator.test.js +++ b/tests/integration/Validation.XmlValidator.test.js @@ -19,7 +19,7 @@ Copyright (c) OWASP Foundation. All Rights Reserved. const assert = require('node:assert') -const { describe, it } = require('mocha') +const { before, describe, it } = require('mocha') const { Spec: { Version }, @@ -28,13 +28,16 @@ const { XmlValidator } } = require('../../') -const { default: xmlValidator } = require('../../dist.node/_optPlug.node/xmlValidator') describe('integration.Validation.XmlValidator', () => { - const expectMissingDepError = xmlValidator.fails ?? false; + before(function () { + if (XmlValidator.cannot !== false) { + this.skip() + } + }); [ - 'somthing-unexpected' + 'something-unexpected' ].forEach((version) => describe(version, () => { it('throws not implemented', async () => { const validator = new XmlValidator(version) @@ -55,17 +58,6 @@ describe('integration.Validation.XmlValidator', () => { Version.v1dot1, Version.v1dot0 ].forEach((version) => describe(version, () => { - if (expectMissingDepError) { - it('throws MissingOptionalDependencyError', async () => { - const validator = new XmlValidator(version) - await assert.rejects( - validator.validate(''), - (err) => err instanceof MissingOptionalDependencyError - ) - }) - return // skip other tests - } - it('invalid throws', async () => { const validator = new XmlValidator(version) const input = ` @@ -99,3 +91,19 @@ describe('integration.Validation.XmlValidator', () => { }) })) }) + +describe('integration.Validation.XmlValidator cannot', () => { + before(function () { + if (XmlValidator.cannot === false) { + this.skip() + } + }) + + it('deps missing throws MissingOptionalDependencyError', async () => { + const validator = new XmlValidator(Version.v1dot7) + await assert.rejects( + validator.validate(''), + (err) => err instanceof MissingOptionalDependencyError && err.message === XmlValidator.cannot.message + ) + }) +})