-
Notifications
You must be signed in to change notification settings - Fork 407
Expand file tree
/
Copy pathapproval.js
More file actions
392 lines (368 loc) · 13.9 KB
/
Copy pathapproval.js
File metadata and controls
392 lines (368 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
'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 { getManifestInputFiles } = require('./runner-command')
const APPROVAL_DIGEST_PATTERN = /^[a-f0-9]{64}$/
const MAX_CAPTURED_PROJECT_SOURCE_BYTES = 512 * 1024
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 includeLocal = requestedScenario !== 'ci-wiring'
const executableIdentities = includeLocal ? 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),
},
projectFiles: getApprovalProjectFiles(manifest, { includeLocal }),
selection: {
frameworks: [...selectedFrameworkIds],
scenario: requestedScenario,
},
validation: {
outputDirectory: path.resolve(out),
offlineFixtureNonce,
keepTemporaryFiles: keepTempFiles,
requiredCapabilities: getRequiredCapabilities({
manifest,
requestedScenario,
selectedFrameworkIds,
}),
verbose,
},
fixtureRecipeDigests: includeLocal
? getFixtureRecipeDigests({
frameworks: manifest.frameworks || [],
selectedFrameworkIds,
requestedScenario,
})
: [],
commands: getManifestCommands(manifest, requestedScenario)
.map(([id, command]) => getApprovalCommand(id, command)),
generatedFiles: getGeneratedFileMaterial(manifest, requestedScenario),
executables: executableIdentities,
}
}
// Capability metadata is approval-only; the validator does not request permissions or start prerequisites.
function getRequiredCapabilities ({ manifest, requestedScenario, selectedFrameworkIds = [] }) {
if (requestedScenario === 'ci-wiring') return []
const selected = new Set(selectedFrameworkIds)
const frameworks = (manifest.frameworks || []).filter(framework => {
return framework.status === 'runnable' && (selected.size === 0 || selected.has(framework.id))
})
const capabilities = new Set()
if (frameworks.some(framework => ['cypress', 'playwright'].includes(framework.framework) ||
(framework.framework === 'vitest' && framework.validation?.runnerArgs?.includes('--browser')) ||
framework.browserRequired === true)) {
capabilities.add('browser_process')
}
if (frameworks.some(framework => framework.localSocketRequired === true ||
(framework.validation?.fallbackTests || []).some(fallback => fallback.localSocketRequired === true))) {
capabilities.add('localhost_socket')
}
return [...capabilities].sort()
}
function getApprovalProjectFiles (manifest, { includeLocal = true } = {}) {
return getApprovalProjectSnapshot(manifest, { includeLocal }).projectFiles
}
// Hashes and static-analysis sources come from the same reads to avoid approval-time races.
function getApprovalProjectSnapshot (manifest, { includeLocal = true } = {}) {
const projectFiles = []
const sources = new Map()
for (const filename of getManifestInputFiles(manifest, { includeLocal })) {
const stat = fs.lstatSync(filename)
const contents = fs.readFileSync(filename)
projectFiles.push({
path: filename,
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
})
sources.set(
filename,
stat.isFile() && !stat.isSymbolicLink() && contents.length <= MAX_CAPTURED_PROJECT_SOURCE_BYTES
? contents
: undefined
)
}
return { projectFiles, sources }
}
/**
* 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 || {},
inheritedEnvironmentNames: command.requiredEnvVars || [],
...getCommandExecutionSettings(command),
outputPaths: getCommandOutputPaths(command),
argv: command.argv,
}
return sanitizeForReport(shape)
}
/**
* Returns exact generated test source and cleanup policy covered by the manifest digest.
*
* @param {object} manifest loaded manifest
* @param {string|null} requestedScenario selected validator scenario
* @returns {object[]} generated file approval material
*/
function getGeneratedFileMaterial (manifest, requestedScenario) {
const files = []
if (manifest.frameworks) {
for (const framework of manifest.frameworks) {
const strategy = framework.generatedTestStrategy
const selectedPaths = getSelectedGeneratedPaths(strategy, requestedScenario)
if (strategy?.files) {
for (const file of strategy.files) {
if (!selectedPaths.has(path.resolve(file.path))) continue
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
}
/**
* Returns generated and support files used by the selected feature.
*
* @param {object|undefined} strategy generated strategy
* @param {string|null} requestedScenario selected validator scenario
* @returns {Set<string>} selected absolute paths
*/
function getSelectedGeneratedPaths (strategy, requestedScenario) {
if (!strategy || requestedScenario === 'basic-reporting' || requestedScenario === 'ci-wiring') return new Set()
const generatedId = {
atr: 'atr-fail-once',
efd: 'basic-pass',
'test-management': 'test-management-target',
}[requestedScenario]
const scenarioPaths = new Set((strategy.scenarios || []).map(scenario => {
return path.resolve(scenario.testIdentities[0].file)
}))
const selectedPaths = new Set()
if (strategy.files) {
for (const file of strategy.files) {
const filename = path.resolve(file.path)
if (!requestedScenario || !scenarioPaths.has(filename)) selectedPaths.add(filename)
}
}
if (generatedId) {
const scenario = strategy.scenarios?.find(candidate => candidate.id === generatedId)
if (scenario) selectedPaths.add(path.resolve(scenario.testIdentities[0].file))
}
return selectedPaths
}
/**
* 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,
getApprovalProjectSnapshot,
getRequiredCapabilities,
serializeApprovalMaterial,
}