From 9389d502c4ce9b69585f8eafe02187cc3d84969b Mon Sep 17 00:00:00 2001 From: bendo-eXX Date: Tue, 31 Mar 2026 10:26:32 +0200 Subject: [PATCH] fix(CSAF2.1): fix mandatoryTest_6_1_3.js --- csaf_2_1/mandatoryTests.js | 2 +- .../mandatoryTests/mandatoryTest_6_1_3.js | 201 ++++++++++++++++++ tests/csaf_2_1/mandatoryTest_6_1_3.js | 19 ++ tests/csaf_2_1/oasis.js | 1 - 4 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 csaf_2_1/mandatoryTests/mandatoryTest_6_1_3.js create mode 100644 tests/csaf_2_1/mandatoryTest_6_1_3.js diff --git a/csaf_2_1/mandatoryTests.js b/csaf_2_1/mandatoryTests.js index c302572e..54e901ed 100644 --- a/csaf_2_1/mandatoryTests.js +++ b/csaf_2_1/mandatoryTests.js @@ -1,6 +1,5 @@ export { mandatoryTest_6_1_2, - mandatoryTest_6_1_3, mandatoryTest_6_1_4, mandatoryTest_6_1_5, mandatoryTest_6_1_12, @@ -36,6 +35,7 @@ export { mandatoryTest_6_1_33, } from '../mandatoryTests.js' export { mandatoryTest_6_1_1 } from './mandatoryTests/mandatoryTest_6_1_1.js' +export { mandatoryTest_6_1_3 } from './mandatoryTests/mandatoryTest_6_1_3.js' export { mandatoryTest_6_1_6 } from './mandatoryTests/mandatoryTest_6_1_6.js' export { mandatoryTest_6_1_7 } from './mandatoryTests/mandatoryTest_6_1_7.js' export { mandatoryTest_6_1_8 } from './mandatoryTests/mandatoryTest_6_1_8.js' diff --git a/csaf_2_1/mandatoryTests/mandatoryTest_6_1_3.js b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_3.js new file mode 100644 index 00000000..a91f865e --- /dev/null +++ b/csaf_2_1/mandatoryTests/mandatoryTest_6_1_3.js @@ -0,0 +1,201 @@ +import Ajv from 'ajv/dist/jtd.js' + +const ajv = new Ajv() + +const fullProductNameSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + name: { type: 'string' }, + product_id: { type: 'string' }, + }, +}) + +const subpathSchema = /** @type {const} */ ({ + additionalProperties: false, + optionalProperties: { + category: { type: 'string' }, + next_product_reference: { type: 'string' }, + }, +}) + +const productPathSchema = /** @type {const} */ ({ + additionalProperties: false, + properties: { + beginning_product_reference: { type: 'string' }, + full_product_name: fullProductNameSchema, + subpaths: { + elements: subpathSchema, + }, + }, +}) + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + optionalProperties: { + product_tree: { + additionalProperties: true, + optionalProperties: { + product_paths: { + elements: productPathSchema, + }, + }, + }, + }, +}) + +const validate = ajv.compile(inputSchema) + +/** + * @typedef {import('ajv/dist/core').JTDDataType} ProductPath + * @typedef {{dependencies: Array<{ productId: string, pathIndex: number, type: "main" | "sub", subIndex: number | null }> }} GraphNode + * @typedef {Map} DependencyGraph + * @typedef {{cycleProductId: string, pathIndex: number, type: string, subIndex: number | null } | null} CircularDependency + */ + +/** + * This implements the mandatory test 6.1.3 of the CSAF 2.1 standard. + * + * @param {any} doc + */ +export function mandatoryTest_6_1_3(doc) { + const ctx = { + errors: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + isValid: true, + } + + if (!validate(doc)) { + return ctx + } + + if (!Array.isArray(doc.product_tree?.product_paths)) { + return ctx + } + + const productPaths = doc.product_tree.product_paths + const graph = buildDependencyGraph(productPaths) + + const visited = new Set() + for (const [productId] of graph) { + if (!visited.has(productId)) { + /** @type {CircularDependency} */ + const circle = hasCircle(productId, graph, visited) + if (circle) { + ctx.isValid = false + const instancePath = + circle.type === 'sub' + ? `/product_tree/product_paths/${circle.pathIndex}/subpaths/${circle.subIndex}/next_product_reference` + : `/product_tree/product_paths/${circle.pathIndex}/full_product_name/product_id` + + ctx.errors.push({ + instancePath, + message: `circular reference detected for product_id: ${circle.cycleProductId}`, + }) + } + } + } + + return ctx +} + +/** + * Adds a directed edge from `from` to `to` in the graph. The edge is annotated with the path index and type. + * @param {DependencyGraph} graph + * @param {string} from + * @param {string} to + * @param {number} pathIndex + * @param {"main" | "sub"} type the 'type' specifies is it from the main part of a product_path or from a subpath + * @param {number | null} subIndex + */ +function addEdge(graph, from, to, pathIndex, type, subIndex = null) { + if (!graph.has(from)) { + graph.set(from, { dependencies: [] }) + } + + const deps = graph.get(from)?.dependencies + + const exists = deps?.some( + (d) => d.productId === to && d.pathIndex === pathIndex + ) + + if (!exists) { + deps?.push({ productId: to, pathIndex, type, subIndex }) + } +} + +/** + * Builds a dependency graph from the given product paths. + * @param {ProductPath[]} productPaths + */ +function buildDependencyGraph(productPaths) { + /** @type {DependencyGraph} */ + const graph = new Map() + + productPaths.forEach((path, index) => { + const beginningProductReference = path.beginning_product_reference + const productId = path.full_product_name.product_id + + if (!beginningProductReference || !productId) return + + addEdge(graph, beginningProductReference, productId, index, 'main') + + path.subpaths?.forEach((sub, subIndex) => { + if (sub.next_product_reference) { + addEdge( + graph, + productId, + sub.next_product_reference, + index, + 'sub', + subIndex + ) + } + }) + }) + return graph +} + +/** + * Detects if there is a circular reference starting from the given productId in the dependency graph. + * @param {string} productId + * @param {DependencyGraph} graph + * @param {Set} visited + * @param {Set} recursionStack + * @returns {CircularDependency} + */ +function hasCircle( + productId, + graph, + visited = new Set(), + recursionStack = new Set() +) { + if (visited.has(productId)) return null + + visited.add(productId) + recursionStack.add(productId) + const node = graph.get(productId) + + if (node) { + for (const dep of node.dependencies) { + if (recursionStack.has(dep.productId)) { + return { + cycleProductId: dep.productId, + pathIndex: dep.pathIndex, + type: dep.type, + subIndex: dep.subIndex ?? null, + } + } + const result = hasCircle(dep.productId, graph, visited, recursionStack) + if (result) return result + } + } + + recursionStack.delete(productId) + return null +} diff --git a/tests/csaf_2_1/mandatoryTest_6_1_3.js b/tests/csaf_2_1/mandatoryTest_6_1_3.js new file mode 100644 index 00000000..1b0f2af1 --- /dev/null +++ b/tests/csaf_2_1/mandatoryTest_6_1_3.js @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { mandatoryTest_6_1_3 } from '../../csaf_2_1/mandatoryTests/mandatoryTest_6_1_3.js' + +describe('mandatoryTest_6_1_3 (CSAF 2.1)', function () { + it('only runs on relevant documents', function () { + assert.equal(mandatoryTest_6_1_3({ document: 'mydoc' }).isValid, true) + }) + + it('returns valid when product_paths is not an array', function () { + const doc = mandatoryTest_6_1_3({ + document: 'mydoc', + product_tree: { + product_paths: 'not_an_array', + }, + }) + assert.equal(doc.isValid, true) + assert.equal(doc.errors.length, 0) + }) +}) diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index 69ed16f3..96c54e0f 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -97,7 +97,6 @@ const excluded = [ */ const skippedTests = new Set([ 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-01-12.json', - 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-03-01.json', 'mandatory/oasis_csaf_tc-csaf_2_1-2024-6-1-27-05-03.json', ])