diff --git a/HISTORY.md b/HISTORY.md index e0e63ed7b..57376bb54 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -8,7 +8,11 @@ All notable changes to this project will be documented in this file. * Fixed * Type exports for the web (via [#1252]) +* Added + * New class `Utils.LicenseUtility.LicenseEvidenceGatherer` ([#1162] via [#1249]) +[#1162]: https://github.com/CycloneDX/cyclonedx-javascript-library/issues/1162 +[#1249]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1249 [#1252]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/1252 ## 8.3.0 -- 2025-06-05 diff --git a/package.json b/package.json index 6b3b1acc4..037acea89 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "c8": "^10", "deepmerge": "^4.2.2", "fast-glob": "^3.3.1", + "memfs": "4.17.2", "mocha": "11.5.0", "npm-run-all2": "^8", "rimraf": "^6", @@ -190,7 +191,7 @@ "test:lint": "tsc --noEmit", "test:standard": "npm --prefix tools/code-style exec -- eslint .", "cs-fix": "npm --prefix tools/code-style exec -- eslint --fix .", - "api-doc": "run-p --aggregate-output -lc api-doc:*", + "api-doc": "run-p --aggregate-output -lc api-doc:\\*", "api-doc:node": "npm --prefix tools/docs-gen exec -- typedoc --options ./typedoc.node.json", "api-doc:web": "npm --prefix tools/docs-gen exec -- typedoc --options ./typedoc.web.json" } diff --git a/src/_helpers/mime.node.ts b/src/_helpers/mime.node.ts new file mode 100644 index 000000000..b5f89dca6 --- /dev/null +++ b/src/_helpers/mime.node.ts @@ -0,0 +1,57 @@ +/*! +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 { parse as parsePath } from 'node:path' + +type MimeType = string + +const MIMETYPE_TEXT_PLAIN: MimeType = 'text/plain' + +const MAP_TEXT_EXTENSION_MIMETYPE: Readonly> = { + '': MIMETYPE_TEXT_PLAIN, + // https://www.iana.org/assignments/media-types/media-types.xhtml + '.csv': 'text/csv', + '.htm': 'text/html', + '.html': 'text/html', + '.md': 'text/markdown', + '.txt': MIMETYPE_TEXT_PLAIN, + '.rst': 'text/prs.fallenstein.rst', + '.rtf': 'application/rtf', // our scope is text, yes, but RTF is binary - so we should base64 encode it ... + '.xml': 'text/xml', // not `application/xml` -- our scope is text! + // add more mime types above this line. pull-requests welcome! + // license-specific files + '.license': MIMETYPE_TEXT_PLAIN, + '.licence': MIMETYPE_TEXT_PLAIN, +} as const + +const LICENSE_FILENAME_BASE: Readonly> = new Set(['licence', 'license']) +const LICENSE_FILENAME_EXT: Readonly> = new Set([ + '.apache', + '.bsd', + '.gpl', + '.mit', + // to be continued ... pullrequests welcome +]) + +export function guessMimeTypeForLicenseFile (filename: string): MimeType | undefined { + const {name, ext} = parsePath(filename.toLowerCase()) + return LICENSE_FILENAME_BASE.has(name) && LICENSE_FILENAME_EXT.has(ext) + ? MIMETYPE_TEXT_PLAIN + : MAP_TEXT_EXTENSION_MIMETYPE[ext] +} diff --git a/src/utils/index.node.ts b/src/utils/index.node.ts index 48df09c1f..de45da246 100644 --- a/src/utils/index.node.ts +++ b/src/utils/index.node.ts @@ -21,6 +21,7 @@ export * from './index.common' // region node-specifics +export * as LicenseUtility from './licenseUtility.node' export * as NpmjsUtility from './npmjsUtility.node' // endregion node-specifics diff --git a/src/utils/licenseUtility.node.ts b/src/utils/licenseUtility.node.ts new file mode 100644 index 000000000..e6b66462e --- /dev/null +++ b/src/utils/licenseUtility.node.ts @@ -0,0 +1,104 @@ +/*! +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. +*/ + +/** + * This module tries to be as compatible as possible, it only uses basic methods that are known to be working in all FileSystem abstraction-layers. + * In addition, we use type parameters for all `PathLike`s, so downstream users can utilize their implementations accordingly. + * + * @module + */ + +import type { Stats } from 'node:fs' + +import { guessMimeTypeForLicenseFile } from '../_helpers/mime.node' +import { AttachmentEncoding } from '../enums/attachmentEncoding' +import { Attachment } from '../models/attachment' + +export interface FsUtils

{ + readdirSync: (path: P ) => P[] + readFileSync: (path: P) => Buffer + statSync: (path: P) => Stats +} + +export interface PathUtils

{ + join: (...paths: P[]) => P +} + +export interface FileAttachment

{ + filePath: P + file: P + text: Attachment +} + +const LICENSE_FILENAME_PATTERN = /^(?:UN)?LICEN[CS]E|.\.LICEN[CS]E$|^NOTICE$/i + +export type ErrorReporter = (e:Error) => any + +export class LicenseEvidenceGatherer

{ + readonly #fs: FsUtils

+ readonly #path: PathUtils

+ + /* eslint-disable tsdoc/syntax -- we want to use the dot-syntax - https://github.com/microsoft/tsdoc/issues/19 */ + /** + * `fs` and `path` can be supplied, if any compatibility-layer or drop-in replacement is used. + * + * @param options.fs - If omitted, the native `node:fs` is used. + * @param options.path - If omitted, the native `node:path` is used. + */ + constructor (options: { fs?: FsUtils

, path?: PathUtils

} = {}) { + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports -- needed */ + this.#fs = options.fs ?? require('node:fs') + this.#path = options.path ?? require('node:path') + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports */ + } + /* eslint-enable tsdoc/syntax */ + + * getFileAttachments (prefixPath: P, onError: ErrorReporter = noop): Generator> { + const files = this.#fs.readdirSync(prefixPath) // may throw + for (const file of files) { + if (!LICENSE_FILENAME_PATTERN.test(file)) { + continue + } + const filePath = this.#path.join(prefixPath, file) + if (!this.#fs.statSync(filePath).isFile()) { + // Ignore all directories - they are not files :-) + // Don't follow symlinks for security reasons! + continue + } + const contentType = guessMimeTypeForLicenseFile(file) + if (contentType === undefined) { + continue + } + try { + yield { filePath, file, text: new Attachment( + // since we cannot be sure weather the file content is text-only, or maybe binary, + // we tend to base64 everything, regardless of the detected encoding. + this.#fs.readFileSync(filePath) // may throw + .toString('base64'), + { contentType, encoding: AttachmentEncoding.Base64 } + ) } + } catch (cause) { + onError(new Error(`skipped license file ${filePath}`, {cause})) + } + } + } +} + +/* eslint-disable-next-line @typescript-eslint/no-empty-function -- ack */ +function noop ():void {} diff --git a/tests/integration/Utils.LicenseUtility.LicenseEvidenceGatherer.node.test.js b/tests/integration/Utils.LicenseUtility.LicenseEvidenceGatherer.node.test.js new file mode 100644 index 000000000..ded6b551f --- /dev/null +++ b/tests/integration/Utils.LicenseUtility.LicenseEvidenceGatherer.node.test.js @@ -0,0 +1,226 @@ +/*! +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. +*/ + +const assert = require('node:assert') +const { sep } = require('node:path') + +const { memfs } = require('memfs') +const { suite, test } = require('mocha') + +const { + Models: { Attachment }, + Enums: { AttachmentEncoding }, + Utils: { LicenseUtility: { LicenseEvidenceGatherer } } +} = require('../../') + +suite('integration: Utils.LicenseUtility.LicenseEvidenceGatherer', () => { + test('no path -> throws', () => { + const { fs } = memfs({ '/': {} }) + const leg = new LicenseEvidenceGatherer({ fs }) + assert.throws( + () => { + Array.from( + leg.getFileAttachments('/foo')) + }, + { + code: 'ENOENT', + message: "ENOENT: no such file or directory, scandir '/foo'", + path: '/foo', + } + ) + }) + + test('no files', () => { + const { fs } = memfs({ '/': {} }) + const leg = new LicenseEvidenceGatherer({ fs }) + const errors = [] + const found = Array.from( + leg.getFileAttachments( + '/', + (e) => { errors.push(e) } + )) + assert.deepEqual(found, []) + assert.deepEqual(errors, []) + }) + + test('ignore LICENSE folder', () => { + const { fs } = memfs({ + LICENSE: { + 'MIT.txt': 'MIT License text here...', + 'GPL-3.0-or-later.txt': 'GPL-3.0-or-later License text here...' + } + }) + const leg = new LicenseEvidenceGatherer({ fs }) + const found = Array.from( + leg.getFileAttachments('/')) + assert.deepEqual(found, []) + }) + + test('ignore LICENSES folder', () => { + // see https://reuse-standard.org/ + const { fs } = memfs({ + LICENSES: { + 'MIT.txt': 'MIT License text here...', + 'GPL-3.0-or-later.txt': 'GPL-3.0-or-later License text here...' + } + }) + const leg = new LicenseEvidenceGatherer({ fs }) + const found = Array.from( + leg.getFileAttachments('/')) + assert.deepEqual(found, []) + }) + + test('reports unreadable files', () => { + // see https://reuse-standard.org/ + const { fs } = memfs({ '/LICENSE': 'license text here...' }) + const expectedError = new Error( + `skipped license file ${sep}LICENSE`, + { cause: new Error('Custom read error: Access denied!') }) + fs.readFileSync = function () { throw expectedError.cause } + const leg = new LicenseEvidenceGatherer({ fs }) + const errors = [] + const found = Array.from( + leg.getFileAttachments( + '/', + (e) => { errors.push(e) } + )) + assert.deepEqual(found, []) + assert.deepEqual(errors, [expectedError]) + }) + + const mockedLicenses = Object.freeze({ + LICENSE: 'LICENSE file expected', + LICENCE: 'LICENCE file expected', + UNLICENSE: 'UNLICENSE file expected', + NOTICE: 'NOTICE file expected', + '---some-.licenses-below': 'unexpected file', + 'MIT.license': 'MIT.license file expected', + 'MIT.licence': 'MIT.licence file expected', + '---some-licenses.-below': 'unexpected file', + 'license.mit': 'license.mit file expected', + 'license.txt': 'license.txt file expected', + 'license.js': 'license.js file unexpected', + 'license.foo': 'license.foo file unexpected', + }) + + /* eslint-disable jsdoc/valid-types -- eslint/jsdoc does not jet known import syntax */ + /** + * @param {import('../../').Utils.LicenseUtility.FileAttachment} a + * @param {import('../../').Utils.LicenseUtility.FileAttachment} b + * @return {number} + */ + function orderByFilePath (a, b) { + return a.filePath.localeCompare(b.filePath) + } + /* eslint-enable jsdoc/valid-types */ + + test('finds licenses as expected', () => { + const { fs } = memfs({ '/': mockedLicenses }) + const leg = new LicenseEvidenceGatherer({ fs }) + const errors = [] + const found = Array.from( + leg.getFileAttachments( + '/', + (e) => { errors.push(e) } + )) + assert.deepEqual(found.sort(orderByFilePath), [ + { + filePath: `${sep}LICENSE`, + file: 'LICENSE', + text: new Attachment( + 'TElDRU5TRSBmaWxlIGV4cGVjdGVk', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}LICENCE`, + file: 'LICENCE', + text: new Attachment( + 'TElDRU5DRSBmaWxlIGV4cGVjdGVk', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}UNLICENSE`, + file: 'UNLICENSE', + text: new Attachment( + 'VU5MSUNFTlNFIGZpbGUgZXhwZWN0ZWQ=', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}NOTICE`, + file: 'NOTICE', + text: new Attachment( + 'Tk9USUNFIGZpbGUgZXhwZWN0ZWQ=', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}MIT.license`, + file: 'MIT.license', + text: new Attachment( + 'TUlULmxpY2Vuc2UgZmlsZSBleHBlY3RlZA==', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}MIT.licence`, + file: 'MIT.licence', + text: new Attachment( + 'TUlULmxpY2VuY2UgZmlsZSBleHBlY3RlZA==', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}license.mit`, + file: 'license.mit', + text: new Attachment( + 'bGljZW5zZS5taXQgZmlsZSBleHBlY3RlZA==', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + }, + { + filePath: `${sep}license.txt`, + file: 'license.txt', + text: new Attachment( + 'bGljZW5zZS50eHQgZmlsZSBleHBlY3RlZA==', { + contentType: 'text/plain', + encoding: AttachmentEncoding.Base64 + }) + } + ].sort(orderByFilePath)) + assert.deepEqual(errors, []) + }) + + test('does not find licenses in subfolder', () => { + const { fs } = memfs({ '/foo': mockedLicenses }) + const leg = new LicenseEvidenceGatherer({ fs }) + const found = Array.from( + leg.getFileAttachments('/')) + assert.deepEqual(found, []) + }) +}) diff --git a/tests/unit/internals/helpers.mime.node.spec.js b/tests/unit/internals/helpers.mime.node.spec.js new file mode 100644 index 000000000..a4e31426d --- /dev/null +++ b/tests/unit/internals/helpers.mime.node.spec.js @@ -0,0 +1,43 @@ +/*! +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. +*/ + +const assert = require('node:assert') + +const { suite, test } = require('mocha') + +const { + guessMimeTypeForLicenseFile +} = require('../../../dist.node/_helpers/mime.node.js') + +suite('unit: internals: helpers.mime.getMimeForLicenseFile', () => { + for (const [fileName, expected] of [ + ['LICENCE', 'text/plain'], + ['site.html', 'text/html'], + ['license.md', 'text/markdown'], + ['info.xml', 'text/xml'], + ['UNKNOWN', 'text/plain'], + ['LICENCE.MIT', 'text/plain'], + ['mit.license', 'text/plain'] + ]) { + test(fileName, () => { + const value = guessMimeTypeForLicenseFile(fileName) + assert.strictEqual(value, expected) + }) + } +})