model-eol 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -20
- package/SPEC.md +21 -6
- package/action.yml +30 -9
- package/bot/bot.mjs +361 -165
- package/bot/lib/git.mjs +49 -0
- package/bot/lib/github.mjs +4 -0
- package/bot.yml.example +20 -11
- package/check.mjs +55 -4
- package/examples/model-eol-eval.mjs +84 -0
- package/examples/workflows/model-eol.yml +52 -0
- package/feeds/amazon.json +5 -1
- package/feeds/anthropic.json +4 -1
- package/lib/apply.mjs +393 -231
- package/lib/config.mjs +16 -1
- package/lib/feeds.mjs +22 -3
- package/lib/json-schema.mjs +291 -0
- package/lib/reports.mjs +95 -22
- package/lib/scanner.mjs +159 -25
- package/lib/validate-document.mjs +320 -0
- package/lib/validate-feed.mjs +7 -1
- package/package.json +4 -3
- package/schema/model-eol.alert.schema.json +46 -10
- package/schema/model-eol.bot-config.schema.json +8 -4
- package/schema/model-eol.check.schema.json +56 -0
- package/schema/model-eol.inventory.schema.json +82 -81
- package/schema/model-eol.plan.schema.json +5 -5
- package/schema/model-eol.schedule.schema.json +10 -10
- package/schema/model-eol.schema.json +24 -8
package/lib/apply.mjs
CHANGED
|
@@ -2,7 +2,12 @@ import crypto from 'node:crypto'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { assertValidPlanDocument, readJsonDocument, validatePlanItems } from './validate-document.mjs'
|
|
6
|
+
|
|
7
|
+
export { validatePlanItems }
|
|
8
|
+
|
|
5
9
|
const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
|
|
10
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
6
11
|
|
|
7
12
|
const occurrenceIndex = (line, value, occurrence) => {
|
|
8
13
|
if (!value || !Number.isInteger(occurrence) || occurrence < 0) return -1
|
|
@@ -22,63 +27,93 @@ const printError = (item, message) => {
|
|
|
22
27
|
console.error(`model-eol: apply refused ${itemLabel(item)}: ${message}`)
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
export const validatePlanItems = items => {
|
|
26
|
-
if (!Array.isArray(items)) throw new Error('plan must contain an items array')
|
|
27
|
-
for (const [index, item] of items.entries()) {
|
|
28
|
-
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
29
|
-
throw new Error(`plan item ${index} must be an object`)
|
|
30
|
-
}
|
|
31
|
-
if (typeof item.file !== 'string' || item.file.length === 0) {
|
|
32
|
-
throw new Error(`plan item ${index} file must be a non-empty string`)
|
|
33
|
-
}
|
|
34
|
-
if (!Number.isInteger(item.line) || item.line < 1) {
|
|
35
|
-
throw new Error(`plan item ${index} line must be an integer >= 1`)
|
|
36
|
-
}
|
|
37
|
-
if (!Number.isInteger(item.occurrence) || item.occurrence < 0) {
|
|
38
|
-
throw new Error(`plan item ${index} occurrence must be an integer >= 0`)
|
|
39
|
-
}
|
|
40
|
-
if (typeof item.matched !== 'string' || item.matched.length === 0) {
|
|
41
|
-
throw new Error(`plan item ${index} matched must be a non-empty string`)
|
|
42
|
-
}
|
|
43
|
-
if (typeof item.replacement !== 'string' || item.replacement.length === 0) {
|
|
44
|
-
throw new Error(`plan item ${index} replacement must be a non-empty string`)
|
|
45
|
-
}
|
|
46
|
-
if (typeof item.expected_line_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.expected_line_sha256)) {
|
|
47
|
-
throw new Error(`plan item ${index} expected_line_sha256 must be 64 lowercase hexadecimal characters`)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return items
|
|
51
|
-
}
|
|
52
|
-
|
|
53
30
|
const isInside = (root, candidate) => {
|
|
54
31
|
const relative = path.relative(root, candidate)
|
|
55
32
|
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
56
33
|
}
|
|
57
34
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
35
|
+
const resolvePlanFile = (rootDir, file) => {
|
|
36
|
+
const lexicalRoot = path.resolve(rootDir)
|
|
37
|
+
if (file.split(/[\\/]/).includes('..')) throw new Error('file path contains .. traversal')
|
|
38
|
+
const resolved = path.resolve(lexicalRoot, file)
|
|
39
|
+
if (isInside(lexicalRoot, resolved)) return resolved
|
|
40
|
+
if (!path.isAbsolute(file)) throw new Error('file resolves outside rootDir')
|
|
41
|
+
|
|
42
|
+
// Remap only system-level root aliases such as macOS /var -> /private/var.
|
|
43
|
+
const physicalRoot = fs.realpathSync(lexicalRoot)
|
|
44
|
+
let candidateRoot = path.dirname(resolved)
|
|
61
45
|
while (true) {
|
|
62
46
|
try {
|
|
63
|
-
|
|
47
|
+
if (fs.realpathSync(candidateRoot) === physicalRoot) {
|
|
48
|
+
const relative = path.relative(candidateRoot, resolved)
|
|
49
|
+
const remapped = path.resolve(lexicalRoot, relative)
|
|
50
|
+
if (isInside(lexicalRoot, remapped)) return remapped
|
|
51
|
+
}
|
|
64
52
|
} catch {
|
|
65
|
-
const parent = path.dirname(current)
|
|
66
|
-
if (parent === current) return value
|
|
67
|
-
suffix.unshift(path.basename(current))
|
|
68
|
-
current = parent
|
|
69
53
|
}
|
|
54
|
+
const parent = path.dirname(candidateRoot)
|
|
55
|
+
if (parent === candidateRoot) break
|
|
56
|
+
candidateRoot = parent
|
|
70
57
|
}
|
|
58
|
+
throw new Error('file resolves outside rootDir')
|
|
71
59
|
}
|
|
72
60
|
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
61
|
+
const assertSafePath = (rootDir, file) => {
|
|
62
|
+
const root = path.resolve(rootDir)
|
|
63
|
+
if (!isInside(root, file)) throw new Error('file resolves outside rootDir')
|
|
64
|
+
|
|
65
|
+
const rootStat = fs.lstatSync(root)
|
|
66
|
+
if (rootStat.isSymbolicLink()) throw new Error(`file path contains symlink component: ${root}`)
|
|
67
|
+
if (!rootStat.isDirectory()) throw new Error(`rootDir is not a directory: ${root}`)
|
|
68
|
+
|
|
69
|
+
const relative = path.relative(root, file)
|
|
70
|
+
const components = relative === '' ? [] : relative.split(path.sep)
|
|
71
|
+
let current = root
|
|
72
|
+
let currentStat = rootStat
|
|
73
|
+
for (const [index, component] of components.entries()) {
|
|
74
|
+
current = path.join(current, component)
|
|
75
|
+
currentStat = fs.lstatSync(current)
|
|
76
|
+
if (currentStat.isSymbolicLink()) throw new Error(`file path contains symlink component: ${current}`)
|
|
77
|
+
if (index < components.length - 1 && !currentStat.isDirectory()) {
|
|
78
|
+
throw new Error(`parent path component is not a directory: ${current}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!currentStat.isFile()) throw new Error(`target is not a regular file: ${file}`)
|
|
82
|
+
return currentStat
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const readTarget = (rootDir, file) => {
|
|
86
|
+
assertSafePath(rootDir, file)
|
|
87
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0
|
|
88
|
+
let descriptor
|
|
89
|
+
let stat
|
|
90
|
+
let buffer
|
|
91
|
+
try {
|
|
92
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow)
|
|
93
|
+
stat = fs.fstatSync(descriptor)
|
|
94
|
+
if (!stat.isFile()) throw new Error(`target is not a regular file: ${file}`)
|
|
95
|
+
buffer = fs.readFileSync(descriptor)
|
|
96
|
+
} finally {
|
|
97
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pathStat = assertSafePath(rootDir, file)
|
|
101
|
+
if (pathStat.dev !== stat.dev || pathStat.ino !== stat.ino) {
|
|
102
|
+
throw new Error(`target changed while it was being read: ${file}`)
|
|
103
|
+
}
|
|
104
|
+
let text
|
|
105
|
+
try {
|
|
106
|
+
text = utf8Decoder.decode(buffer)
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`target is not valid UTF-8: ${file}`)
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
buffer,
|
|
112
|
+
text,
|
|
113
|
+
dev: stat.dev,
|
|
114
|
+
ino: stat.ino,
|
|
115
|
+
mode: stat.mode & 0o7777,
|
|
116
|
+
}
|
|
82
117
|
}
|
|
83
118
|
|
|
84
119
|
const groupItems = items => {
|
|
@@ -120,16 +155,21 @@ const locateOccurrences = (line, items) => {
|
|
|
120
155
|
const locateReplacements = (line, items) => {
|
|
121
156
|
const located = locateOccurrences(line, items)
|
|
122
157
|
if (located.failures.length) return located
|
|
123
|
-
const seen = new Set()
|
|
124
158
|
const failures = []
|
|
125
159
|
const locations = []
|
|
126
160
|
for (const location of located.locations) {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
161
|
+
const end = location.index + location.item.matched.length
|
|
162
|
+
const conflict = locations.find(existing => (
|
|
163
|
+
location.index < existing.index + existing.item.matched.length &&
|
|
164
|
+
existing.index < end
|
|
165
|
+
))
|
|
166
|
+
if (conflict) {
|
|
167
|
+
const message = conflict.index === location.index
|
|
168
|
+
? 'duplicate occurrence in plan'
|
|
169
|
+
: 'replacement span overlaps another plan item'
|
|
170
|
+
failures.push({ item: location.item, message })
|
|
130
171
|
continue
|
|
131
172
|
}
|
|
132
|
-
seen.add(key)
|
|
133
173
|
locations.push(location)
|
|
134
174
|
}
|
|
135
175
|
return { locations, failures }
|
|
@@ -186,224 +226,346 @@ const restoreGroup = (postImage, items) => {
|
|
|
186
226
|
return search(postImage, descriptors)
|
|
187
227
|
}
|
|
188
228
|
|
|
189
|
-
const temporaryFileFor = file => path.join(
|
|
229
|
+
const temporaryFileFor = (file, purpose) => path.join(
|
|
190
230
|
path.dirname(file),
|
|
191
|
-
`.model-eol-${path.basename(file)}-${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`,
|
|
231
|
+
`.model-eol-${path.basename(file)}-${process.pid}-${purpose}-${crypto.randomBytes(12).toString('hex')}.tmp`,
|
|
192
232
|
)
|
|
193
233
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
234
|
+
const sortedItems = items => items.toSorted((a, b) => a.planIndex - b.planIndex)
|
|
235
|
+
|
|
236
|
+
const analyzeFile = (file, items, snapshot) => {
|
|
237
|
+
const lines = snapshot.text.split('\n')
|
|
238
|
+
const ready = []
|
|
239
|
+
const already = []
|
|
240
|
+
const failures = []
|
|
241
|
+
const readyGroups = []
|
|
242
|
+
|
|
243
|
+
for (const [lineNumber, lineItems] of groupItemsByLine(items)) {
|
|
244
|
+
const line = lines[lineNumber - 1]
|
|
245
|
+
if (line === undefined) {
|
|
246
|
+
for (const item of lineItems) failures.push({ item, message: 'line is not present' })
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (lineItems.length > MAX_RESTORE_ITEMS) {
|
|
251
|
+
for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (hasExpectedPreImage(line, lineItems)) {
|
|
256
|
+
const located = locateReplacements(line, lineItems)
|
|
257
|
+
failures.push(...located.failures)
|
|
258
|
+
for (const location of located.locations) {
|
|
259
|
+
ready.push(location.item)
|
|
260
|
+
}
|
|
261
|
+
readyGroups.push({
|
|
262
|
+
lineNumber,
|
|
263
|
+
items: located.locations.map(location => location.item),
|
|
264
|
+
locations: located.locations,
|
|
265
|
+
})
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (restoreGroup(line, lineItems) !== null) already.push(...lineItems)
|
|
270
|
+
else {
|
|
271
|
+
for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const updatedLines = lines.slice()
|
|
276
|
+
for (const { lineNumber, locations } of readyGroups) {
|
|
277
|
+
updatedLines[lineNumber - 1] = applyLocatedReplacements(lines[lineNumber - 1], locations)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
file,
|
|
282
|
+
items,
|
|
283
|
+
snapshot,
|
|
284
|
+
ready: sortedItems(ready),
|
|
285
|
+
already: sortedItems(already),
|
|
286
|
+
failures,
|
|
287
|
+
updatedContent: updatedLines.join('\n'),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const inspectFiles = (rootDir, prepared) => {
|
|
292
|
+
const analyses = []
|
|
293
|
+
const failures = []
|
|
294
|
+
for (const [file, items] of groupItems(prepared)) {
|
|
202
295
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
296
|
+
const analysis = analyzeFile(file, items, readTarget(rootDir, file))
|
|
297
|
+
analyses.push(analysis)
|
|
298
|
+
failures.push(...analysis.failures)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
for (const item of items) failures.push({ item, message: `unreadable file: ${error.message}` })
|
|
205
301
|
}
|
|
206
|
-
throw error
|
|
207
302
|
}
|
|
303
|
+
return { analyses, failures }
|
|
208
304
|
}
|
|
209
305
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
306
|
+
const uniqueFailures = failures => {
|
|
307
|
+
const seen = new Set()
|
|
308
|
+
return failures
|
|
309
|
+
.toSorted((a, b) => a.item.planIndex - b.item.planIndex)
|
|
310
|
+
.filter(failure => {
|
|
311
|
+
if (seen.has(failure.item)) return false
|
|
312
|
+
seen.add(failure.item)
|
|
313
|
+
return true
|
|
314
|
+
})
|
|
315
|
+
}
|
|
215
316
|
|
|
216
|
-
|
|
217
|
-
let applied = 0
|
|
317
|
+
const reportAlready = (analyses, excluded = new Set()) => {
|
|
218
318
|
let alreadyApplied = 0
|
|
319
|
+
const items = sortedItems(analyses.flatMap(analysis => analysis.already))
|
|
320
|
+
for (const item of items) {
|
|
321
|
+
if (excluded.has(item)) continue
|
|
322
|
+
console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
|
|
323
|
+
alreadyApplied++
|
|
324
|
+
}
|
|
325
|
+
return alreadyApplied
|
|
326
|
+
}
|
|
219
327
|
|
|
220
|
-
|
|
221
|
-
|
|
328
|
+
const reportRefusal = (analyses, failures, cascadeMessage) => {
|
|
329
|
+
const specific = uniqueFailures(failures)
|
|
330
|
+
const refused = new Set(specific.map(failure => failure.item))
|
|
331
|
+
for (const failure of specific) printError(failure.item, failure.message)
|
|
332
|
+
|
|
333
|
+
let failed = specific.length
|
|
334
|
+
const ready = sortedItems(analyses.flatMap(analysis => analysis.ready))
|
|
335
|
+
for (const item of ready) {
|
|
336
|
+
if (refused.has(item)) continue
|
|
337
|
+
printError(item, cascadeMessage)
|
|
338
|
+
refused.add(item)
|
|
339
|
+
failed++
|
|
340
|
+
}
|
|
341
|
+
const alreadyApplied = reportAlready(analyses, refused)
|
|
342
|
+
return { failed, applied: 0, alreadyApplied }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const snapshotsMatch = (left, right) => (
|
|
346
|
+
left.dev === right.dev &&
|
|
347
|
+
left.ino === right.ino &&
|
|
348
|
+
left.mode === right.mode &&
|
|
349
|
+
left.buffer.equals(right.buffer)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
const verifySnapshots = (rootDir, analyses) => {
|
|
353
|
+
const failures = []
|
|
354
|
+
for (const analysis of analyses) {
|
|
222
355
|
try {
|
|
223
|
-
|
|
356
|
+
const latest = readTarget(rootDir, analysis.file)
|
|
357
|
+
if (!snapshotsMatch(latest, analysis.snapshot)) {
|
|
358
|
+
for (const item of analysis.items) failures.push({ item, message: 'target changed after validation' })
|
|
359
|
+
}
|
|
224
360
|
} catch (error) {
|
|
225
|
-
|
|
226
|
-
failed++
|
|
361
|
+
for (const item of analysis.items) failures.push({ item, message: `write failed: ${error.message}` })
|
|
227
362
|
}
|
|
228
363
|
}
|
|
364
|
+
return failures
|
|
365
|
+
}
|
|
229
366
|
|
|
230
|
-
|
|
231
|
-
|
|
367
|
+
const writeStagedFile = (file, content, mode, created) => {
|
|
368
|
+
const descriptor = fs.openSync(
|
|
369
|
+
file,
|
|
370
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
371
|
+
mode,
|
|
372
|
+
)
|
|
373
|
+
created.push(file)
|
|
374
|
+
try {
|
|
375
|
+
fs.writeFileSync(descriptor, content)
|
|
376
|
+
} finally {
|
|
377
|
+
fs.closeSync(descriptor)
|
|
378
|
+
}
|
|
379
|
+
fs.chmodSync(file, mode)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const removeTemporaryFiles = (temporaryFiles, unlinkSync) => {
|
|
383
|
+
const failures = []
|
|
384
|
+
for (const temporaryFile of temporaryFiles) {
|
|
385
|
+
if (!temporaryFile) continue
|
|
232
386
|
try {
|
|
233
|
-
|
|
234
|
-
} catch (
|
|
235
|
-
|
|
236
|
-
printError(item, `unreadable file: ${e.message}`)
|
|
237
|
-
failed++
|
|
238
|
-
}
|
|
239
|
-
continue
|
|
387
|
+
unlinkSync(temporaryFile)
|
|
388
|
+
} catch (error) {
|
|
389
|
+
if (error?.code !== 'ENOENT') failures.push({ temporaryFile, error })
|
|
240
390
|
}
|
|
391
|
+
}
|
|
392
|
+
return failures
|
|
393
|
+
}
|
|
241
394
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const failures = []
|
|
246
|
-
|
|
247
|
-
const readyGroups = []
|
|
248
|
-
for (const [lineNumber, lineItems] of groupItemsByLine(items)) {
|
|
249
|
-
const line = lines[lineNumber - 1]
|
|
250
|
-
if (line === undefined) {
|
|
251
|
-
for (const item of lineItems) failures.push({ item, message: 'line is not present' })
|
|
252
|
-
continue
|
|
253
|
-
}
|
|
395
|
+
const cleanupFailureDetail = failures => failures.length
|
|
396
|
+
? `; temporary cleanup failed (${failures.map(({ temporaryFile, error }) => `${temporaryFile}: ${error.message}`).join('; ')})`
|
|
397
|
+
: ''
|
|
254
398
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
399
|
+
const reportCleanupFailures = failures => {
|
|
400
|
+
for (const { temporaryFile, error } of failures) {
|
|
401
|
+
console.error(`model-eol: apply cleanup failed ${temporaryFile}: ${error.message}`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
259
404
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
405
|
+
const stageAnalysis = (rootDir, analysis, unlinkSync) => {
|
|
406
|
+
assertSafePath(rootDir, analysis.file)
|
|
407
|
+
const nextPath = temporaryFileFor(analysis.file, 'next')
|
|
408
|
+
const backupPath = temporaryFileFor(analysis.file, 'backup')
|
|
409
|
+
const created = []
|
|
410
|
+
try {
|
|
411
|
+
writeStagedFile(nextPath, Buffer.from(analysis.updatedContent, 'utf8'), analysis.snapshot.mode, created)
|
|
412
|
+
writeStagedFile(backupPath, analysis.snapshot.buffer, analysis.snapshot.mode, created)
|
|
413
|
+
return { analysis, nextPath, backupPath, keepBackup: false }
|
|
414
|
+
} catch (error) {
|
|
415
|
+
const cleanupFailures = removeTemporaryFiles(created, unlinkSync)
|
|
416
|
+
throw new Error(`${error.message}${cleanupFailureDetail(cleanupFailures)}`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
269
419
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
420
|
+
const cleanupStages = (stages, unlinkSync) => removeTemporaryFiles(
|
|
421
|
+
stages.flatMap(stage => [stage.nextPath, stage.keepBackup ? null : stage.backupPath]),
|
|
422
|
+
unlinkSync,
|
|
423
|
+
)
|
|
275
424
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
for (const failure of failures) {
|
|
281
|
-
printError(failure.item, failure.message)
|
|
282
|
-
failed++
|
|
283
|
-
}
|
|
284
|
-
for (const item of ready) {
|
|
285
|
-
printError(item, 'another item for this file failed validation; file was not changed')
|
|
286
|
-
failed++
|
|
287
|
-
}
|
|
288
|
-
for (const item of already) {
|
|
289
|
-
console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
|
|
290
|
-
alreadyApplied++
|
|
291
|
-
}
|
|
292
|
-
continue
|
|
293
|
-
}
|
|
425
|
+
export const applyPlan = ({ planPath, dryRun = false, rootDir, _test } = {}) => {
|
|
426
|
+
if (typeof rootDir !== 'string' || rootDir.length === 0) throw new Error('applyPlan requires rootDir')
|
|
427
|
+
const plan = readJsonDocument(planPath)
|
|
428
|
+
assertValidPlanDocument(plan)
|
|
294
429
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
readyGroup.items = readyGroup.items.filter(item => !duplicateItems.has(item))
|
|
303
|
-
}
|
|
304
|
-
if (duplicateFailures.length) {
|
|
305
|
-
ready = ready.filter(item => !duplicateItems.has(item))
|
|
306
|
-
failures.push(...duplicateFailures)
|
|
307
|
-
ready.sort((a, b) => a.planIndex - b.planIndex)
|
|
308
|
-
already.sort((a, b) => a.planIndex - b.planIndex)
|
|
309
|
-
failures.sort((a, b) => a.item.planIndex - b.item.planIndex)
|
|
310
|
-
for (const failure of failures) {
|
|
311
|
-
printError(failure.item, failure.message)
|
|
312
|
-
failed++
|
|
313
|
-
}
|
|
314
|
-
for (const item of ready) {
|
|
315
|
-
printError(item, 'another item for this file failed validation; file was not changed')
|
|
316
|
-
failed++
|
|
317
|
-
}
|
|
318
|
-
continue
|
|
430
|
+
const prepared = []
|
|
431
|
+
const pathFailures = []
|
|
432
|
+
for (const [planIndex, item] of plan.items.entries()) {
|
|
433
|
+
try {
|
|
434
|
+
prepared.push({ ...item, planIndex, resolvedFile: resolvePlanFile(rootDir, item.file) })
|
|
435
|
+
} catch (error) {
|
|
436
|
+
pathFailures.push({ item: { ...item, planIndex }, message: error.message })
|
|
319
437
|
}
|
|
438
|
+
}
|
|
320
439
|
|
|
321
|
-
|
|
322
|
-
|
|
440
|
+
const initial = inspectFiles(rootDir, prepared)
|
|
441
|
+
const initialFailures = pathFailures.concat(initial.failures)
|
|
442
|
+
if (initialFailures.length) {
|
|
443
|
+
return reportRefusal(
|
|
444
|
+
initial.analyses,
|
|
445
|
+
initialFailures,
|
|
446
|
+
'another item in this plan failed validation; no files were changed',
|
|
447
|
+
)
|
|
448
|
+
}
|
|
323
449
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
450
|
+
const initiallyReady = initial.analyses.flatMap(analysis => analysis.ready)
|
|
451
|
+
if (dryRun) {
|
|
452
|
+
const alreadyApplied = reportAlready(initial.analyses)
|
|
453
|
+
for (const item of sortedItems(initiallyReady)) {
|
|
454
|
+
console.log(`would change ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
|
|
327
455
|
}
|
|
328
|
-
|
|
456
|
+
return { failed: 0, applied: 0, alreadyApplied }
|
|
457
|
+
}
|
|
458
|
+
if (!initiallyReady.length) {
|
|
459
|
+
return { failed: 0, applied: 0, alreadyApplied: reportAlready(initial.analyses) }
|
|
460
|
+
}
|
|
329
461
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
462
|
+
// Re-read every plan target together before staging output.
|
|
463
|
+
const current = inspectFiles(rootDir, prepared)
|
|
464
|
+
if (current.failures.length) {
|
|
465
|
+
return reportRefusal(
|
|
466
|
+
current.analyses,
|
|
467
|
+
current.failures,
|
|
468
|
+
'another item in this plan failed revalidation; no files were changed',
|
|
469
|
+
)
|
|
470
|
+
}
|
|
335
471
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
continue
|
|
341
|
-
}
|
|
472
|
+
const currentReady = current.analyses.flatMap(analysis => analysis.ready)
|
|
473
|
+
if (!currentReady.length) {
|
|
474
|
+
return { failed: 0, applied: 0, alreadyApplied: reportAlready(current.analyses) }
|
|
475
|
+
}
|
|
342
476
|
|
|
343
|
-
|
|
477
|
+
const unlinkSync = typeof _test?.unlinkSync === 'function'
|
|
478
|
+
? temporaryFile => _test.unlinkSync(temporaryFile)
|
|
479
|
+
: temporaryFile => fs.unlinkSync(temporaryFile)
|
|
480
|
+
|
|
481
|
+
const stages = []
|
|
482
|
+
let stagingFailure = null
|
|
483
|
+
for (const analysis of current.analyses) {
|
|
484
|
+
if (!analysis.ready.length) continue
|
|
344
485
|
try {
|
|
345
|
-
|
|
346
|
-
} catch (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
failed++
|
|
350
|
-
}
|
|
351
|
-
continue
|
|
352
|
-
}
|
|
353
|
-
const latestLines = latestText.split('\n')
|
|
354
|
-
const latestReadyGroups = []
|
|
355
|
-
const verificationFailures = []
|
|
356
|
-
for (const { lineNumber, items: lineItems } of readyGroups) {
|
|
357
|
-
const line = latestLines[lineNumber - 1]
|
|
358
|
-
if (line === undefined) {
|
|
359
|
-
for (const item of lineItems) verificationFailures.push({ item, message: 'line is not present' })
|
|
360
|
-
continue
|
|
361
|
-
}
|
|
362
|
-
if (!hasExpectedPreImage(line, lineItems)) {
|
|
363
|
-
for (const item of lineItems) verificationFailures.push({ item, message: 'line hash does not match expected_line_sha256' })
|
|
364
|
-
continue
|
|
365
|
-
}
|
|
366
|
-
const located = locateReplacements(line, lineItems)
|
|
367
|
-
if (located.failures.length) {
|
|
368
|
-
verificationFailures.push(...located.failures)
|
|
369
|
-
continue
|
|
370
|
-
}
|
|
371
|
-
latestReadyGroups.push({ lineNumber, locations: located.locations })
|
|
372
|
-
}
|
|
373
|
-
verificationFailures.sort((a, b) => a.item.planIndex - b.item.planIndex)
|
|
374
|
-
if (verificationFailures.length) {
|
|
375
|
-
for (const failure of verificationFailures) {
|
|
376
|
-
printError(failure.item, failure.message)
|
|
377
|
-
failed++
|
|
378
|
-
}
|
|
379
|
-
const verificationFailedItems = new Set(verificationFailures.map(failure => failure.item))
|
|
380
|
-
for (const item of ready) {
|
|
381
|
-
if (!verificationFailedItems.has(item)) {
|
|
382
|
-
printError(item, 'another item for this file failed validation; file was not changed')
|
|
383
|
-
failed++
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
continue
|
|
486
|
+
stages.push(stageAnalysis(rootDir, analysis, unlinkSync))
|
|
487
|
+
} catch (error) {
|
|
488
|
+
stagingFailure = { analysis, error }
|
|
489
|
+
break
|
|
387
490
|
}
|
|
491
|
+
}
|
|
492
|
+
if (stagingFailure) {
|
|
493
|
+
reportCleanupFailures(cleanupStages(stages, unlinkSync))
|
|
494
|
+
return reportRefusal(
|
|
495
|
+
current.analyses,
|
|
496
|
+
stagingFailure.analysis.ready.map(item => ({ item, message: `write failed: ${stagingFailure.error.message}` })),
|
|
497
|
+
'another file could not be staged; no files were changed',
|
|
498
|
+
)
|
|
499
|
+
}
|
|
388
500
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
501
|
+
// Verify every snapshot again before the first commit rename.
|
|
502
|
+
const precommitFailures = verifySnapshots(rootDir, current.analyses)
|
|
503
|
+
if (precommitFailures.length) {
|
|
504
|
+
reportCleanupFailures(cleanupStages(stages, unlinkSync))
|
|
505
|
+
return reportRefusal(
|
|
506
|
+
current.analyses,
|
|
507
|
+
precommitFailures,
|
|
508
|
+
'another target changed before commit; no files were changed',
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const renameSync = typeof _test?.renameSync === 'function'
|
|
513
|
+
? (source, target, context) => _test.renameSync(source, target, context)
|
|
514
|
+
: (source, target) => fs.renameSync(source, target)
|
|
515
|
+
const committed = []
|
|
516
|
+
let commitFailure = null
|
|
517
|
+
for (const [index, stage] of stages.entries()) {
|
|
393
518
|
try {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
519
|
+
const latest = readTarget(rootDir, stage.analysis.file)
|
|
520
|
+
if (!snapshotsMatch(latest, stage.analysis.snapshot)) throw new Error('target changed after pre-commit verification')
|
|
521
|
+
renameSync(stage.nextPath, stage.analysis.file, { phase: 'commit', index, file: stage.analysis.file })
|
|
522
|
+
stage.nextPath = null
|
|
523
|
+
committed.push(stage)
|
|
524
|
+
} catch (error) {
|
|
525
|
+
commitFailure = { stage, error }
|
|
526
|
+
break
|
|
401
527
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (commitFailure) {
|
|
531
|
+
const rollbackFailures = []
|
|
532
|
+
for (const [rollbackIndex, stage] of committed.toReversed().entries()) {
|
|
533
|
+
try {
|
|
534
|
+
assertSafePath(rootDir, stage.analysis.file)
|
|
535
|
+
renameSync(stage.backupPath, stage.analysis.file, {
|
|
536
|
+
phase: 'rollback',
|
|
537
|
+
index: rollbackIndex,
|
|
538
|
+
file: stage.analysis.file,
|
|
539
|
+
})
|
|
540
|
+
stage.backupPath = null
|
|
541
|
+
} catch (error) {
|
|
542
|
+
stage.keepBackup = true
|
|
543
|
+
rollbackFailures.push({ stage, error })
|
|
544
|
+
}
|
|
405
545
|
}
|
|
546
|
+
reportCleanupFailures(cleanupStages(stages, unlinkSync))
|
|
547
|
+
|
|
548
|
+
const rollbackDetail = rollbackFailures.length
|
|
549
|
+
? `; rollback incomplete (${rollbackFailures.map(({ stage, error }) => `${stage.analysis.file}: ${error.message}; backup retained at ${stage.backupPath}`).join('; ')})`
|
|
550
|
+
: committed.length ? '; earlier file commits were rolled back' : ''
|
|
551
|
+
const cascadeMessage = rollbackFailures.length
|
|
552
|
+
? 'another file failed during plan commit and rollback was incomplete'
|
|
553
|
+
: 'another file failed during plan commit; earlier commits were rolled back'
|
|
554
|
+
return reportRefusal(
|
|
555
|
+
current.analyses,
|
|
556
|
+
commitFailure.stage.analysis.ready.map(item => ({
|
|
557
|
+
item,
|
|
558
|
+
message: `write failed: ${commitFailure.error.message}${rollbackDetail}`,
|
|
559
|
+
})),
|
|
560
|
+
cascadeMessage,
|
|
561
|
+
)
|
|
406
562
|
}
|
|
407
563
|
|
|
408
|
-
|
|
564
|
+
const cleanupFailures = cleanupStages(stages, unlinkSync)
|
|
565
|
+
reportCleanupFailures(cleanupFailures)
|
|
566
|
+
const alreadyApplied = reportAlready(current.analyses)
|
|
567
|
+
for (const item of sortedItems(currentReady)) {
|
|
568
|
+
console.log(`applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
|
|
569
|
+
}
|
|
570
|
+
return { failed: cleanupFailures.length, applied: currentReady.length, alreadyApplied }
|
|
409
571
|
}
|