From 7edb9fb40e265495d4fe9707eeaa89f515c39379 Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov <23283554+r-farkhutdinov@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:15:22 +0300 Subject: [PATCH 1/5] fix(sbom): add hashes for workspace packages --- packages/sbom/scripts/add-workspace-hashes.ts | 52 +++++++++++++++++++ packages/sbom/scripts/make.ts | 4 ++ packages/sbom/scripts/tsconfig.json | 3 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/sbom/scripts/add-workspace-hashes.ts diff --git a/packages/sbom/scripts/add-workspace-hashes.ts b/packages/sbom/scripts/add-workspace-hashes.ts new file mode 100644 index 000000000000..d46b1c0edb7e --- /dev/null +++ b/packages/sbom/scripts/add-workspace-hashes.ts @@ -0,0 +1,52 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { addHashes } from '@devexpress/sbom-toolkit'; +import type { AddHashesEntry, CycloneDXBom } from '@devexpress/sbom-toolkit'; + +const INTERNAL_PACKAGES = ['devextreme-metadata', 'eslint-migration-utils']; + +interface AddWorkspaceHashesConfig { + distDir: string; + repoRoot: string; + packages: { fullName: string; tarball?: string }[]; +} + +export function addWorkspaceHashes({ distDir, repoRoot, packages }: AddWorkspaceHashesConfig): void { + const tarballs = new Map(packages.flatMap(({ fullName, tarball }) => (tarball ? [[fullName, tarball]] : []))); + const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-pack-')); + + const getTarball = (name: string): string | undefined => { + if (!tarballs.has(name) && INTERNAL_PACKAGES.includes(name)) { + tarballs.set(name, pack(path.join(repoRoot, 'packages', name), path.join(packDir, name))); + } + return tarballs.get(name); + }; + + try { + const entries: AddHashesEntry[] = fs.readdirSync(distDir) + .filter((file) => file.endsWith('.cdx.json')) + .flatMap((file) => { + const sbomPath = path.join(distDir, file); + const { components = [] }: CycloneDXBom = JSON.parse(fs.readFileSync(sbomPath, 'utf-8')); + return components.flatMap((component) => { + const name = component.group ? `${component.group}/${component.name}` : component.name; + const tarball = component.hashes?.length ? undefined : getTarball(name); + return tarball ? [{ sbomPath, component: component['bom-ref'], tarball }] : []; + }); + }); + + if (entries.length) { + addHashes({ packages: entries }); + } + } finally { + fs.rmSync(packDir, { recursive: true, force: true }); + } +} + +function pack(dir: string, destination: string): string { + execFileSync('pnpm', ['pack', '--pack-destination', destination], { cwd: dir, stdio: 'ignore' }); + return path.join(destination, fs.readdirSync(destination)[0]); +} diff --git a/packages/sbom/scripts/make.ts b/packages/sbom/scripts/make.ts index 8417e1d10267..ad24e46d8d9a 100644 --- a/packages/sbom/scripts/make.ts +++ b/packages/sbom/scripts/make.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { addDependencies, makeSbom } from '@devexpress/sbom-toolkit'; import type { MakeSbomConfig } from '@devexpress/sbom-toolkit'; +import { addWorkspaceHashes } from './add-workspace-hashes.ts'; + const PACKAGE_DIR = path.resolve(import.meta.dirname, '..'); const REPO_ROOT = path.resolve(PACKAGE_DIR, '../..'); const DIST = path.join(PACKAGE_DIR, 'dist'); @@ -48,6 +50,8 @@ for (const name of PACKAGES_WITH_DEVEXTREME) { } fs.rmSync(EXTRA_SBOM); +addWorkspaceHashes({ distDir: DIST, repoRoot: REPO_ROOT, packages }); + const devextremeSbom = path.join(DIST, 'devextreme.cdx.json'); if (fs.existsSync(devextremeSbom)) { fs.copyFileSync(devextremeSbom, path.join(DIST, 'devextreme-dist.cdx.json')); diff --git a/packages/sbom/scripts/tsconfig.json b/packages/sbom/scripts/tsconfig.json index ea1ca19f6757..4708864bab91 100644 --- a/packages/sbom/scripts/tsconfig.json +++ b/packages/sbom/scripts/tsconfig.json @@ -3,6 +3,7 @@ "module": "preserve", "target": "es2022", "types": ["node"], - "noEmit": true + "noEmit": true, + "allowImportingTsExtensions": true } } From a42854fee489bb1f5d0e673ff123263ea0106f53 Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov <23283554+r-farkhutdinov@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:52:04 +0300 Subject: [PATCH 2/5] refactor(sbom): hash internal packages by folder content, merge SBOM loop --- packages/sbom/scripts/add-workspace-hashes.ts | 77 +++++++++++-------- packages/sbom/scripts/make.ts | 10 +-- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/sbom/scripts/add-workspace-hashes.ts b/packages/sbom/scripts/add-workspace-hashes.ts index d46b1c0edb7e..6f56d1a424fa 100644 --- a/packages/sbom/scripts/add-workspace-hashes.ts +++ b/packages/sbom/scripts/add-workspace-hashes.ts @@ -1,52 +1,61 @@ -import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { addHashes } from '@devexpress/sbom-toolkit'; -import type { AddHashesEntry, CycloneDXBom } from '@devexpress/sbom-toolkit'; +import type { AddHashesEntry, CycloneDXBom, Hash } from '@devexpress/sbom-toolkit'; -const INTERNAL_PACKAGES = ['devextreme-metadata', 'eslint-migration-utils']; +const INTERNAL_PACKAGES: Record = { + 'devextreme-metadata': 'packages/devextreme-metadata/dist', + 'eslint-migration-utils': 'packages/eslint-migration-utils/src', +}; + +const contentHashes = new Map(); interface AddWorkspaceHashesConfig { - distDir: string; + sbomPath: string; repoRoot: string; - packages: { fullName: string; tarball?: string }[]; + tarballs: Map; } -export function addWorkspaceHashes({ distDir, repoRoot, packages }: AddWorkspaceHashesConfig): void { - const tarballs = new Map(packages.flatMap(({ fullName, tarball }) => (tarball ? [[fullName, tarball]] : []))); - const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbom-pack-')); - - const getTarball = (name: string): string | undefined => { - if (!tarballs.has(name) && INTERNAL_PACKAGES.includes(name)) { - tarballs.set(name, pack(path.join(repoRoot, 'packages', name), path.join(packDir, name))); +export function addWorkspaceHashes({ sbomPath, repoRoot, tarballs }: AddWorkspaceHashesConfig): void { + const getContentHash = (name: string): Hash | undefined => { + if (!contentHashes.has(name) && name in INTERNAL_PACKAGES) { + contentHashes.set(name, contentHash(path.join(repoRoot, INTERNAL_PACKAGES[name]))); } - return tarballs.get(name); + return contentHashes.get(name); }; - try { - const entries: AddHashesEntry[] = fs.readdirSync(distDir) - .filter((file) => file.endsWith('.cdx.json')) - .flatMap((file) => { - const sbomPath = path.join(distDir, file); - const { components = [] }: CycloneDXBom = JSON.parse(fs.readFileSync(sbomPath, 'utf-8')); - return components.flatMap((component) => { - const name = component.group ? `${component.group}/${component.name}` : component.name; - const tarball = component.hashes?.length ? undefined : getTarball(name); - return tarball ? [{ sbomPath, component: component['bom-ref'], tarball }] : []; - }); - }); - - if (entries.length) { - addHashes({ packages: entries }); + const { components = [] }: CycloneDXBom = JSON.parse(fs.readFileSync(sbomPath, 'utf-8')); + const entries = components.flatMap((component): AddHashesEntry[] => { + if (component.hashes?.length) { + return []; } - } finally { - fs.rmSync(packDir, { recursive: true, force: true }); + const name = component.group ? `${component.group}/${component.name}` : component.name; + const target = { sbomPath, component: component['bom-ref'] }; + const tarball = tarballs.get(name); + if (tarball) { + return [{ ...target, tarball }]; + } + const hash = getContentHash(name); + return hash ? [{ ...target, hashes: [hash] }] : []; + }); + + if (entries.length) { + addHashes({ packages: entries }); } } -function pack(dir: string, destination: string): string { - execFileSync('pnpm', ['pack', '--pack-destination', destination], { cwd: dir, stdio: 'ignore' }); - return path.join(destination, fs.readdirSync(destination)[0]); +function contentHash(dir: string): Hash { + const hash = createHash('sha512'); + fs.readdirSync(dir, { recursive: true, encoding: 'utf-8' }) + .filter((file) => fs.statSync(path.join(dir, file)).isFile()) + .map((file) => file.split(path.sep).join('/')) + .sort() + .forEach((file) => { + const content = fs.readFileSync(path.join(dir, file)); + // Delimit path and size so different file layouts can't produce the same byte stream. + hash.update(`${file}\0${content.length}\0`).update(content); + }); + return { alg: 'SHA-512', content: hash.digest('hex') }; } diff --git a/packages/sbom/scripts/make.ts b/packages/sbom/scripts/make.ts index ad24e46d8d9a..fa164831b153 100644 --- a/packages/sbom/scripts/make.ts +++ b/packages/sbom/scripts/make.ts @@ -42,16 +42,16 @@ await makeSbom({ }); await makeSbom({ targetDir: REPO_ROOT, outputDir: DIST, packages, options }); -for (const name of PACKAGES_WITH_DEVEXTREME) { - const sbomPath = path.join(DIST, `${name}.cdx.json`); - if (fs.existsSync(sbomPath)) { +const tarballs = new Map(packages.flatMap(({ fullName, tarball }) => (tarball ? [[fullName, tarball]] : []))); +for (const { fullName } of packages) { + const sbomPath = path.join(DIST, `${fullName}.cdx.json`); + if (PACKAGES_WITH_DEVEXTREME.includes(fullName)) { addDependencies({ sbomPath, extraSbomPath: EXTRA_SBOM, component: 'devextreme' }); } + addWorkspaceHashes({ sbomPath, repoRoot: REPO_ROOT, tarballs }); } fs.rmSync(EXTRA_SBOM); -addWorkspaceHashes({ distDir: DIST, repoRoot: REPO_ROOT, packages }); - const devextremeSbom = path.join(DIST, 'devextreme.cdx.json'); if (fs.existsSync(devextremeSbom)) { fs.copyFileSync(devextremeSbom, path.join(DIST, 'devextreme-dist.cdx.json')); From b961c6e45d5c77546f45a0486d96a5c1dd1c07f9 Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov <23283554+r-farkhutdinov@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:07:17 +0300 Subject: [PATCH 3/5] fix(sbom): generate devextreme-metadata before making SBOMs --- packages/sbom/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sbom/project.json b/packages/sbom/project.json index 98dce0aa48ad..66219fa55cb7 100644 --- a/packages/sbom/project.json +++ b/packages/sbom/project.json @@ -11,7 +11,7 @@ }, "make": { "executor": "nx:run-commands", - "dependsOn": ["install-dependencies"], + "dependsOn": ["install-dependencies", "devextreme-metadata:generate"], "options": { "cwd": "{projectRoot}", "forwardAllArgs": false, From 41e72050fdf9d5e102094c9eb824fbb2423d8326 Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov <23283554+r-farkhutdinov@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:04:34 +0300 Subject: [PATCH 4/5] refactor(sbom): compute internal package hashes once --- packages/sbom/scripts/add-workspace-hashes.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/sbom/scripts/add-workspace-hashes.ts b/packages/sbom/scripts/add-workspace-hashes.ts index 6f56d1a424fa..5d30ff7c5059 100644 --- a/packages/sbom/scripts/add-workspace-hashes.ts +++ b/packages/sbom/scripts/add-workspace-hashes.ts @@ -10,7 +10,7 @@ const INTERNAL_PACKAGES: Record = { 'eslint-migration-utils': 'packages/eslint-migration-utils/src', }; -const contentHashes = new Map(); +let contentHashes: Record | undefined; interface AddWorkspaceHashesConfig { sbomPath: string; @@ -19,12 +19,9 @@ interface AddWorkspaceHashesConfig { } export function addWorkspaceHashes({ sbomPath, repoRoot, tarballs }: AddWorkspaceHashesConfig): void { - const getContentHash = (name: string): Hash | undefined => { - if (!contentHashes.has(name) && name in INTERNAL_PACKAGES) { - contentHashes.set(name, contentHash(path.join(repoRoot, INTERNAL_PACKAGES[name]))); - } - return contentHashes.get(name); - }; + const hashes = contentHashes ??= Object.fromEntries( + Object.entries(INTERNAL_PACKAGES).map(([name, dir]) => [name, contentHash(path.join(repoRoot, dir))]), + ); const { components = [] }: CycloneDXBom = JSON.parse(fs.readFileSync(sbomPath, 'utf-8')); const entries = components.flatMap((component): AddHashesEntry[] => { @@ -37,7 +34,7 @@ export function addWorkspaceHashes({ sbomPath, repoRoot, tarballs }: AddWorkspac if (tarball) { return [{ ...target, tarball }]; } - const hash = getContentHash(name); + const hash = Object.hasOwn(hashes, name) ? hashes[name] : undefined; return hash ? [{ ...target, hashes: [hash] }] : []; }); From a1c3cb3f1399cd0752d437bcb1d12ce555ab03a7 Mon Sep 17 00:00:00 2001 From: Ruslan Farkhutdinov <23283554+r-farkhutdinov@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:36:19 +0300 Subject: [PATCH 5/5] refactor(sbom): assert internal package hashes --- packages/sbom/scripts/add-workspace-hashes.ts | 54 ++++++++++--------- packages/sbom/scripts/make.ts | 3 +- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/sbom/scripts/add-workspace-hashes.ts b/packages/sbom/scripts/add-workspace-hashes.ts index 5d30ff7c5059..568e9533d409 100644 --- a/packages/sbom/scripts/add-workspace-hashes.ts +++ b/packages/sbom/scripts/add-workspace-hashes.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -15,44 +16,47 @@ let contentHashes: Record | undefined; interface AddWorkspaceHashesConfig { sbomPath: string; repoRoot: string; - tarballs: Map; } -export function addWorkspaceHashes({ sbomPath, repoRoot, tarballs }: AddWorkspaceHashesConfig): void { - const hashes = contentHashes ??= Object.fromEntries( - Object.entries(INTERNAL_PACKAGES).map(([name, dir]) => [name, contentHash(path.join(repoRoot, dir))]), +export function addWorkspaceHashes({ sbomPath, repoRoot }: AddWorkspaceHashesConfig): void { + contentHashes ??= Object.entries(INTERNAL_PACKAGES).reduce>( + (acc, [name, dir]) => ({ ...acc, [name]: contentHash(path.join(repoRoot, dir)) }), + {}, ); + const hashes = contentHashes; const { components = [] }: CycloneDXBom = JSON.parse(fs.readFileSync(sbomPath, 'utf-8')); - const entries = components.flatMap((component): AddHashesEntry[] => { - if (component.hashes?.length) { - return []; - } - const name = component.group ? `${component.group}/${component.name}` : component.name; - const target = { sbomPath, component: component['bom-ref'] }; - const tarball = tarballs.get(name); - if (tarball) { - return [{ ...target, tarball }]; - } - const hash = Object.hasOwn(hashes, name) ? hashes[name] : undefined; - return hash ? [{ ...target, hashes: [hash] }] : []; - }); + const entries = components + .filter((component) => Object.hasOwn(INTERNAL_PACKAGES, componentName(component))) + .map((component): AddHashesEntry => { + const name = componentName(component); + assert(!component.hashes?.length, `Unexpected pre-existing hash for internal package "${name}" in ${sbomPath}`); + assert(name in hashes, `Missing computed content hash for internal package "${name}"`); + return { sbomPath, component: component['bom-ref'], hashes: [hashes[name]] }; + }); if (entries.length) { addHashes({ packages: entries }); } } +function componentName({ group, name }: CycloneDXBom['components'][number]): string { + return group ? `${group}/${name}` : name; +} + function contentHash(dir: string): Hash { - const hash = createHash('sha512'); - fs.readdirSync(dir, { recursive: true, encoding: 'utf-8' }) + const files = fs.readdirSync(dir, { recursive: true, encoding: 'utf-8' }) .filter((file) => fs.statSync(path.join(dir, file)).isFile()) .map((file) => file.split(path.sep).join('/')) - .sort() - .forEach((file) => { - const content = fs.readFileSync(path.join(dir, file)); - // Delimit path and size so different file layouts can't produce the same byte stream. - hash.update(`${file}\0${content.length}\0`).update(content); - }); + .sort(); + if (!files.length) { + throw new Error(`No files to hash in ${dir}`); + } + + const hash = createHash('sha512'); + files.forEach((file) => { + const content = fs.readFileSync(path.join(dir, file)); + hash.update(`${file}\0${content.length}\0`).update(content); + }); return { alg: 'SHA-512', content: hash.digest('hex') }; } diff --git a/packages/sbom/scripts/make.ts b/packages/sbom/scripts/make.ts index fa164831b153..f9b550d797ca 100644 --- a/packages/sbom/scripts/make.ts +++ b/packages/sbom/scripts/make.ts @@ -42,13 +42,12 @@ await makeSbom({ }); await makeSbom({ targetDir: REPO_ROOT, outputDir: DIST, packages, options }); -const tarballs = new Map(packages.flatMap(({ fullName, tarball }) => (tarball ? [[fullName, tarball]] : []))); for (const { fullName } of packages) { const sbomPath = path.join(DIST, `${fullName}.cdx.json`); if (PACKAGES_WITH_DEVEXTREME.includes(fullName)) { addDependencies({ sbomPath, extraSbomPath: EXTRA_SBOM, component: 'devextreme' }); } - addWorkspaceHashes({ sbomPath, repoRoot: REPO_ROOT, tarballs }); + addWorkspaceHashes({ sbomPath, repoRoot: REPO_ROOT }); } fs.rmSync(EXTRA_SBOM);