diff --git a/README.md b/README.md index 2f16af0f..4ac915ab 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.44 - Recommended Test 6.2.45 - Recommended Test 6.2.46 -- Recommended Test 6.2.49 - Recommended Test 6.2.50.1 - Recommended Test 6.2.50.2 - Recommended Test 6.2.50.3 @@ -501,6 +500,7 @@ export const recommendedTest_6_2_41: DocumentTest export const recommendedTest_6_2_43: DocumentTest export const recommendedTest_6_2_47: DocumentTest export const recommendedTest_6_2_48: DocumentTest +export const recommendedTest_6_2_49: DocumentTest export const recommendedTest_6_2_53: DocumentTest ``` diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 2d1a78d8..1be17864 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -42,4 +42,5 @@ export { recommendedTest_6_2_41 } from './recommendedTests/recommendedTest_6_2_4 export { recommendedTest_6_2_43 } from './recommendedTests/recommendedTest_6_2_43.js' export { recommendedTest_6_2_47 } from './recommendedTests/recommendedTest_6_2_47.js' export { recommendedTest_6_2_48 } from './recommendedTests/recommendedTest_6_2_48.js' +export { recommendedTest_6_2_49 } from './recommendedTests/recommendedTest_6_2_49.js' export { recommendedTest_6_2_53 } from './recommendedTests/recommendedTest_6_2_53.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_49.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_49.js new file mode 100644 index 00000000..d36c1bf2 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_49.js @@ -0,0 +1,84 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { walkPath } from '../../lib/walkPaths.js' + +const ajv = new Ajv() + +const branchSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + category: { type: 'string' }, + name: { type: 'string' }, + }, +}) + +const validateBranch = ajv.compile(branchSchema) + +// regex to match the prefix of a product version range that uses the `vers` comparator +const VERS_PREFIX = /^vers:[a-z\.\-\+][a-z0-9\.\-\+]*\// + +// regex to match an upper open ended constraint, e.g. `>=1.0.0` or `>1.0.0` +const UPPER_OPEN_ENDED_CONSTRAINT = /^>=?/ + +/** + * This implements the recommended test 6.2.49 of the CSAF 2.1 standard. + * + * @param {unknown} doc + */ +export async function recommendedTest_6_2_49(doc) { + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + await walkPath( + doc, + '/product_tree/branches[*]', + async (instancePath, value) => { + if (!validateBranch(value)) return + const branch = /** @type {{ category?: string; name?: string }} */ (value) + + if ( + branch.category !== 'product_version_range' || + typeof branch.name !== 'string' + ) { + return + } + + if (isUpperOpenEnded(branch.name)) { + ctx.warnings.push({ + instancePath: `${instancePath}/name`, + message: `The product version range "${branch.name}" is upper open ended.`, + }) + } + } + ) + + return ctx +} + +/** + * Checks whether the last bound defining constraint of a product version + * range (vers or vls) is upper open ended. + * + * @param {string} name + * @returns {boolean} + */ +function isUpperOpenEnded(name) { + const trimmed = name.trim() + if (trimmed === 'vers:all/*') return false + + const versMatch = trimmed.match(VERS_PREFIX) + const constraints = versMatch ? trimmed.slice(versMatch[0].length) : trimmed + + const boundConstraints = constraints + .split('|') + .map((constraint) => constraint.trim()) + .filter( + (constraint) => constraint.length > 0 && !constraint.startsWith('!=') + ) + + if (boundConstraints.length === 0) return false + + const lastConstraint = boundConstraints[boundConstraints.length - 1] + return UPPER_OPEN_ENDED_CONSTRAINT.test(lastConstraint) +} diff --git a/lib/walkPaths.js b/lib/walkPaths.js index cd674ec9..42f5a584 100644 --- a/lib/walkPaths.js +++ b/lib/walkPaths.js @@ -34,6 +34,9 @@ export async function walkPath(root, path, onCheck) { // Reached the end of the path: call the callback now ... await onCheck('/' + resolvedSegments.join('/'), node) + } else if (typeof node !== 'object') { + // The path continues, but the current node is a primitive and therefore cannot have further + // properties to traverse into. } else if (keyEntry.endsWith('[*]')) { // ... Recursive-descent array: visit every element and re-apply this same // segment on each element to handle arbitrary nesting depth. @@ -63,7 +66,7 @@ export async function walkPath(root, path, onCheck) { const array = Reflect.get(node, arrayName) if (Array.isArray(array)) { - for (const [elementIndex, element] of array?.entries() ?? []) { + for (const [elementIndex, element] of array.entries()) { await walk( [...resolvedSegments, arrayName, String(elementIndex)], [...remainingSegments.slice(1)], diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 0f97584b..bdf34ab4 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -39,7 +39,6 @@ const excluded = [ '6.2.44', '6.2.45', '6.2.46', - '6.2.49', '6.2.50.1', '6.2.50.2', '6.2.50.3', diff --git a/tests/csaf_2_1/recommendedTest_6_2_49.js b/tests/csaf_2_1/recommendedTest_6_2_49.js new file mode 100644 index 00000000..c3c13784 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_49.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { recommendedTest_6_2_49 } from '../../csaf_2_1/recommendedTests/recommendedTest_6_2_49.js' + +describe('recommendedTest_6_2_49', function () { + it('skips invalid child branches that do not pass schema validation', async function () { + const result = await recommendedTest_6_2_49({ + product_tree: { + branches: [42, null], + }, + }) + assert.equal(result.warnings.length, 0) + }) + + it('does not warn for the special "all versions" vers string', async function () { + const result = await recommendedTest_6_2_49({ + product_tree: { + branches: [ + { + category: 'product_version_range', + name: 'vers:all/*', + }, + ], + }, + }) + assert.equal(result.warnings.length, 0) + }) + + it('does not warn when only exclusion constraints are present', async function () { + const result = await recommendedTest_6_2_49({ + product_tree: { + branches: [ + { + category: 'product_version_range', + name: 'vers:intdot/!=5.1|!=6.3.0', + }, + ], + }, + }) + assert.equal(result.warnings.length, 0) + }) +}) diff --git a/tests/walkPaths.js b/tests/walkPaths.js index 05fc4165..2e6d6f90 100644 --- a/tests/walkPaths.js +++ b/tests/walkPaths.js @@ -140,5 +140,15 @@ describe('walkPath', function () { const results = await collect({ items: 'not-an-array' }, '/items[]') expect(results).to.deep.equal([]) }) + + it('does not throw and skips primitive elements when the path continues into them', async function () { + const results = await collect( + { branches: [42, null, { name: 'valid' }] }, + '/branches[*]/name' + ) + expect(results).to.deep.equal([ + { instancePath: '/branches/2/name', value: 'valid' }, + ]) + }) }) })