UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

300 lines (279 loc) 10.2 kB
'use strict' const crypto = require('node:crypto') const fs = require('node:fs') const path = require('node:path') const { getCommandOutputPaths } = require('./command-output-policy') const { getCommandExecutionSettings } = require('./command-runner') const { bindManifestExecutables, getManifestCommands } = require('./executable') const { getFixtureRecipeDigests } = require('./offline-fixtures') const { sanitizeForReport } = require('./redaction') const APPROVAL_DIGEST_PATTERN = /^[a-f0-9]{64}$/ const OFFLINE_FIXTURE_NONCE_PATTERN = /^[a-f0-9]{32}$/ const PACKAGE_SNAPSHOT_EXCLUDED_NAMES = new Set(['.git', '.nyc_output', 'node_modules']) /** * Binds an approval to the exact manifest bytes and live validator options. * * @param {object} input approval inputs * @param {object} input.manifest loaded manifest * @param {string} input.out validation output directory * @param {string[]} [input.selectedFrameworkIds] selected framework ids * @param {string|null} [input.requestedScenario] selected scenario * @param {string} input.offlineFixtureNonce random fixture-root nonce shown in the execution plan * @param {boolean} [input.keepTempFiles] whether generated files are retained * @param {boolean} [input.verbose] whether command progress is printed * @returns {string} SHA-256 approval digest */ function getApprovalDigest ({ manifest, out, selectedFrameworkIds = [], requestedScenario = null, offlineFixtureNonce, keepTempFiles = false, verbose = false, }) { const approvalJson = serializeApprovalMaterial({ manifest, out, selectedFrameworkIds, requestedScenario, offlineFixtureNonce, keepTempFiles, verbose, }) return crypto.createHash('sha256').update(approvalJson).digest('hex') } /** * Builds the complete, inspectable material covered by one approval fingerprint. * * Secret-like values are redacted for the artifact while the raw manifest digest still binds their exact bytes. * * @param {object} input approval inputs * @param {object} input.manifest loaded validation manifest * @param {string} input.out validation output directory * @param {string[]} [input.selectedFrameworkIds] selected framework identifiers * @param {string|null} [input.requestedScenario] selected validation scenario * @param {string} input.offlineFixtureNonce private offline fixture nonce * @param {boolean} [input.keepTempFiles] whether generated files remain after validation * @param {boolean} [input.verbose] whether verbose validation output is enabled * @returns {object} deterministic approval material */ function getApprovalMaterial ({ manifest, out, selectedFrameworkIds = [], requestedScenario = null, offlineFixtureNonce, keepTempFiles = false, verbose = false, }) { if (!OFFLINE_FIXTURE_NONCE_PATTERN.test(String(offlineFixtureNonce || ''))) { throw new Error('Invalid offline fixture nonce. Render a fresh plan with --print-plan.') } const validationDirectory = __dirname const packageRoot = path.resolve(validationDirectory, '..', '..') const packageJsonPath = path.join(packageRoot, 'package.json') const packageMetadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) const packageFiles = getPackageFiles(packageRoot, [ manifest.__path, out, path.join(packageRoot, '.junit-tmp'), ]) const executableIdentities = bindManifestExecutables(manifest) return { schemaVersion: 1, sharingWarning: 'Internal diagnostic material. Review repository paths, commands, and CI metadata before sharing.', validator: { package: packageMetadata.name, version: packageMetadata.version, packageRoot, coveredFiles: packageFiles.map(filename => ({ path: path.relative(packageRoot, filename).split(path.sep).join('/'), sha256: getFileDigest(filename), })), }, manifest: { path: path.resolve(manifest.__path), sha256: getManifestDigest(manifest), }, selection: { frameworks: [...selectedFrameworkIds], scenario: requestedScenario, }, validation: { outputDirectory: path.resolve(out), offlineFixtureNonce, keepTemporaryFiles: keepTempFiles, verbose, }, fixtureRecipeDigests: getFixtureRecipeDigests({ frameworks: manifest.frameworks || [], selectedFrameworkIds, requestedScenario, }), commands: getManifestCommands(manifest).map(([id, command]) => getApprovalCommand(id, command)), generatedFiles: getGeneratedFileMaterial(manifest), executables: executableIdentities, } } /** * Serializes approval material using stable formatting suitable for independent SHA-256 tools. * * @param {object} input approval inputs * @returns {string} UTF-8 JSON text ending in one newline */ function serializeApprovalMaterial (input) { return `${JSON.stringify(getApprovalMaterial(input), null, 2)}\n` } /** * Returns every regular file owned by the installed dd-trace package. * * @param {string} packageRoot installed dd-trace package root * @param {string[]} excludedPaths generated files or directories outside the package snapshot * @returns {string[]} sorted absolute file paths */ function getPackageFiles (packageRoot, excludedPaths) { const files = [] collectPackageFiles( fs.realpathSync(packageRoot), excludedPaths.map(resolvePhysicalPath), files ) return files.sort() } /** * Converts one manifest command into its sanitized, execution-relevant approval shape. * * @param {string} id stable command identifier * @param {object} command structured command * @returns {object} command approval material */ function getApprovalCommand (id, command) { const shape = { id, required: command.required !== false, usesShell: command.usesShell === true, cwd: path.resolve(command.cwd), environmentMode: 'clean', environment: command.env || {}, ...getCommandExecutionSettings(command), outputPaths: getCommandOutputPaths(command), } if (command.usesShell) { shape.shell = command.shell || null shape.shellCommand = command.shellCommand } else { shape.argv = command.argv } return sanitizeForReport(shape) } /** * Returns exact generated test source and cleanup policy covered by the manifest digest. * * @param {object} manifest loaded manifest * @returns {object[]} generated file approval material */ function getGeneratedFileMaterial (manifest) { const files = [] for (const framework of manifest.frameworks || []) { const strategy = framework.generatedTestStrategy for (const file of strategy?.files || []) { const content = `${file.contentLines.join('\n')}\n` files.push(sanitizeForReport({ frameworkId: framework.id, path: path.resolve(file.path), sha256: crypto.createHash('sha256').update(content).digest('hex'), content, removeAfterValidation: (strategy.cleanupPaths || []).some(cleanupPath => { return path.resolve(cleanupPath) === path.resolve(file.path) }), })) } } return files } /** * Hashes one covered regular file. * * @param {string} filename absolute filename * @returns {string} lowercase SHA-256 digest */ function getFileDigest (filename) { return crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex') } /** * Collects regular package files without following package-internal symbolic links. * * @param {string} directory current package directory * @param {string[]} excludedPaths generated paths omitted from the package snapshot * @param {string[]} files collected files */ function collectPackageFiles (directory, excludedPaths, files) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const filename = path.join(directory, entry.name) if (PACKAGE_SNAPSHOT_EXCLUDED_NAMES.has(entry.name) || isExcludedPackagePath(filename, excludedPaths)) continue if (entry.isDirectory()) { collectPackageFiles(filename, excludedPaths, files) } else if (entry.isFile()) { files.push(filename) } } } /** * Checks whether a package path belongs to a generated approval input or output. * * @param {string} filename package path * @param {string[]} excludedPaths generated paths omitted from the package snapshot * @returns {boolean} whether the path is excluded */ function isExcludedPackagePath (filename, excludedPaths) { return excludedPaths.some(excluded => filename === excluded || filename.startsWith(`${excluded}${path.sep}`)) } /** * Resolves an existing path or its nearest existing ancestor through filesystem aliases. * * @param {string} filename path that may not exist yet * @returns {string} physical path */ function resolvePhysicalPath (filename) { const missingSegments = [] let existingPath = path.resolve(filename) while (!fs.existsSync(existingPath)) { missingSegments.unshift(path.basename(existingPath)) const parent = path.dirname(existingPath) if (parent === existingPath) return path.resolve(filename) existingPath = parent } return path.join(fs.realpathSync(existingPath), ...missingSegments) } /** * Validates an approval digest before live validation executes project code. * * @param {string} digest supplied approval digest * @param {object} input approval inputs * @returns {void} */ function assertApprovalDigest (digest, input) { if (!APPROVAL_DIGEST_PATTERN.test(String(digest || ''))) { throw new Error('Invalid approved plan SHA-256. Render a fresh plan with --print-plan.') } const expected = getApprovalDigest(input) if (digest !== expected) { throw new Error( 'The validation manifest or execution options changed after approval. ' + 'Render a fresh plan with --print-plan and approve that exact plan before live validation.' ) } } function getManifestDigest (manifest) { if (manifest.__sourceSha256) return manifest.__sourceSha256 const serializable = { ...manifest } delete serializable.__path return crypto.createHash('sha256').update(JSON.stringify(serializable)).digest('hex') } module.exports = { assertApprovalDigest, getApprovalDigest, getApprovalMaterial, serializeApprovalMaterial, }