@getmikk/core 2.0.12 → 2.0.14
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 +12 -3
- package/package.json +1 -1
- package/src/analysis/index.ts +9 -0
- package/src/analysis/taint-analysis.ts +419 -0
- package/src/analysis/type-flow.ts +247 -0
- package/src/cache/incremental-cache.ts +272 -0
- package/src/cache/index.ts +1 -0
- package/src/contract/adr-manager.ts +5 -4
- package/src/contract/contract-generator.ts +31 -3
- package/src/contract/contract-writer.ts +3 -2
- package/src/contract/lock-compiler.ts +34 -0
- package/src/contract/lock-reader.ts +62 -5
- package/src/contract/schema.ts +10 -0
- package/src/index.ts +14 -1
- package/src/parser/error-recovery.ts +646 -0
- package/src/parser/index.ts +330 -74
- package/src/parser/oxc-parser.ts +3 -2
- package/src/parser/tree-sitter/parser.ts +59 -9
- package/src/parser/tree-sitter/queries.ts +27 -0
- package/src/parser/types.ts +1 -1
- package/src/security/index.ts +1 -0
- package/src/security/scanner.ts +342 -0
- package/src/utils/artifact-transaction.ts +176 -0
- package/src/utils/atomic-write.ts +131 -0
- package/src/utils/fs.ts +76 -25
- package/src/utils/language-registry.ts +95 -0
- package/src/utils/minimatch.ts +49 -6
- package/tests/adr-manager.test.ts +6 -0
- package/tests/artifact-transaction.test.ts +73 -0
- package/tests/contract.test.ts +12 -0
- package/tests/dead-code.test.ts +12 -0
- package/tests/esm-resolver.test.ts +6 -0
- package/tests/fs.test.ts +22 -1
- package/tests/fuzzy-match.test.ts +6 -0
- package/tests/go-parser.test.ts +7 -0
- package/tests/graph.test.ts +10 -0
- package/tests/hash.test.ts +6 -0
- package/tests/impact-classified.test.ts +13 -0
- package/tests/js-parser.test.ts +10 -0
- package/tests/language-registry.test.ts +64 -0
- package/tests/parse-diagnostics.test.ts +115 -0
- package/tests/parser.test.ts +36 -0
- package/tests/tree-sitter-parser.test.ts +201 -0
- package/tests/ts-parser.test.ts +6 -0
package/src/parser/types.ts
CHANGED
|
@@ -116,7 +116,7 @@ export interface ParsedRoute {
|
|
|
116
116
|
/** Everything extracted from a single file */
|
|
117
117
|
export interface ParsedFile {
|
|
118
118
|
path: string; // normalized absolute path
|
|
119
|
-
language: 'python' | 'go' | 'typescript' | 'javascript' | 'java' | 'c' | 'cpp' | 'csharp' | 'rust' | 'php' | 'ruby' | 'unknown';
|
|
119
|
+
language: 'python' | 'go' | 'typescript' | 'javascript' | 'java' | 'kotlin' | 'swift' | 'c' | 'cpp' | 'csharp' | 'rust' | 'php' | 'ruby' | 'unknown';
|
|
120
120
|
functions: ParsedFunction[];
|
|
121
121
|
classes: ParsedClass[];
|
|
122
122
|
variables: ParsedVariable[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SecurityScanner, type SecurityFinding, type SecurityReport } from './scanner.js'
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Security Vulnerability Scanning — foundation for detecting common patterns
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface SecurityFinding {
|
|
6
|
+
id: string
|
|
7
|
+
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
|
|
8
|
+
category: string
|
|
9
|
+
title: string
|
|
10
|
+
description: string
|
|
11
|
+
file: string
|
|
12
|
+
line: number
|
|
13
|
+
column?: number
|
|
14
|
+
code: string
|
|
15
|
+
suggestion?: string
|
|
16
|
+
cwe?: string
|
|
17
|
+
cve?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SecurityReport {
|
|
21
|
+
findings: SecurityFinding[]
|
|
22
|
+
summary: {
|
|
23
|
+
total: number
|
|
24
|
+
critical: number
|
|
25
|
+
high: number
|
|
26
|
+
medium: number
|
|
27
|
+
low: number
|
|
28
|
+
info: number
|
|
29
|
+
}
|
|
30
|
+
scannedFiles: number
|
|
31
|
+
scanDuration: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Pattern definitions for common vulnerability categories
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
interface VulnerabilityPattern {
|
|
39
|
+
id: string
|
|
40
|
+
severity: SecurityFinding['severity']
|
|
41
|
+
category: string
|
|
42
|
+
title: string
|
|
43
|
+
description: string
|
|
44
|
+
regex: RegExp
|
|
45
|
+
suggestion?: string
|
|
46
|
+
cwe?: string
|
|
47
|
+
languages?: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VULNERABILITY_PATTERNS: VulnerabilityPattern[] = [
|
|
51
|
+
// SQL Injection
|
|
52
|
+
{
|
|
53
|
+
id: 'sql-injection',
|
|
54
|
+
severity: 'critical',
|
|
55
|
+
category: 'injection',
|
|
56
|
+
title: 'Potential SQL Injection',
|
|
57
|
+
description: 'String concatenation in SQL query detected. Use parameterized queries instead.',
|
|
58
|
+
regex: /(?:execute|query|cursor\.execute)\s*\(\s*["'].*(?:\+|\$\{)/,
|
|
59
|
+
suggestion: 'Use parameterized queries: cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))',
|
|
60
|
+
cwe: 'CWE-89',
|
|
61
|
+
languages: ['python', 'javascript', 'typescript'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'sql-injection-fstring',
|
|
65
|
+
severity: 'critical',
|
|
66
|
+
category: 'injection',
|
|
67
|
+
title: 'SQL Injection via f-string',
|
|
68
|
+
description: 'f-string used in SQL query. Use parameterized queries.',
|
|
69
|
+
regex: /(?:execute|query)\s*\(\s*f["']/,
|
|
70
|
+
suggestion: 'Use parameterized queries instead of f-strings in SQL.',
|
|
71
|
+
cwe: 'CWE-89',
|
|
72
|
+
languages: ['python'],
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Command Injection
|
|
76
|
+
{
|
|
77
|
+
id: 'command-injection',
|
|
78
|
+
severity: 'critical',
|
|
79
|
+
category: 'injection',
|
|
80
|
+
title: 'Potential Command Injection',
|
|
81
|
+
description: 'User input may be passed to shell command. Use subprocess with list args instead.',
|
|
82
|
+
regex: /(?:os\.system|subprocess\.call|subprocess\.Popen|exec|eval)\s*\(\s*(?:.*\+|.*\$\{)/,
|
|
83
|
+
suggestion: 'Use subprocess.run() with a list of arguments instead of shell=True.',
|
|
84
|
+
cwe: 'CWE-78',
|
|
85
|
+
languages: ['python'],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'eval-usage',
|
|
89
|
+
severity: 'high',
|
|
90
|
+
category: 'injection',
|
|
91
|
+
title: 'Use of eval()',
|
|
92
|
+
description: 'eval() can execute arbitrary code. Use ast.literal_eval() for safe parsing.',
|
|
93
|
+
regex: /\beval\s*\(/,
|
|
94
|
+
suggestion: 'Use ast.literal_eval() for parsing Python literals, or json.loads() for JSON.',
|
|
95
|
+
cwe: 'CWE-95',
|
|
96
|
+
languages: ['python', 'javascript', 'typescript'],
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Hardcoded Secrets
|
|
100
|
+
{
|
|
101
|
+
id: 'hardcoded-password',
|
|
102
|
+
severity: 'high',
|
|
103
|
+
category: 'secrets',
|
|
104
|
+
title: 'Hardcoded Password',
|
|
105
|
+
description: 'Password appears to be hardcoded in source code.',
|
|
106
|
+
regex: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{3,}["']/i,
|
|
107
|
+
suggestion: 'Use environment variables or a secrets manager.',
|
|
108
|
+
cwe: 'CWE-798',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'hardcoded-api-key',
|
|
112
|
+
severity: 'high',
|
|
113
|
+
category: 'secrets',
|
|
114
|
+
title: 'Hardcoded API Key',
|
|
115
|
+
description: 'API key or token appears to be hardcoded.',
|
|
116
|
+
regex: /(?:api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*["'][A-Za-z0-9_-]{8,}["']/i,
|
|
117
|
+
suggestion: 'Use environment variables or a secrets manager.',
|
|
118
|
+
cwe: 'CWE-798',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'aws-key',
|
|
122
|
+
severity: 'critical',
|
|
123
|
+
category: 'secrets',
|
|
124
|
+
title: 'AWS Access Key',
|
|
125
|
+
description: 'AWS access key pattern detected.',
|
|
126
|
+
regex: /AKIA[0-9A-Z]{16}/,
|
|
127
|
+
suggestion: 'Remove AWS credentials from source code. Use IAM roles or environment variables.',
|
|
128
|
+
cwe: 'CWE-798',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'private-key',
|
|
132
|
+
severity: 'critical',
|
|
133
|
+
category: 'secrets',
|
|
134
|
+
title: 'Private Key',
|
|
135
|
+
description: 'Private key content detected in source code.',
|
|
136
|
+
regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/,
|
|
137
|
+
suggestion: 'Never embed private keys in source code. Use a secrets manager.',
|
|
138
|
+
cwe: 'CWE-798',
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// XSS
|
|
142
|
+
{
|
|
143
|
+
id: 'xss-innerhtml',
|
|
144
|
+
severity: 'high',
|
|
145
|
+
category: 'xss',
|
|
146
|
+
title: 'Potential XSS via innerHTML',
|
|
147
|
+
description: 'Setting innerHTML with dynamic content can lead to XSS.',
|
|
148
|
+
regex: /\.innerHTML\s*=\s*(?!["']\s*;?\s*$)/,
|
|
149
|
+
suggestion: 'Use textContent or sanitize HTML with DOMPurify.',
|
|
150
|
+
cwe: 'CWE-79',
|
|
151
|
+
languages: ['javascript', 'typescript'],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'xss-dangerouslySetInnerHTML',
|
|
155
|
+
severity: 'high',
|
|
156
|
+
category: 'xss',
|
|
157
|
+
title: 'Potential XSS via dangerouslySetInnerHTML',
|
|
158
|
+
description: 'dangerouslySetInnerHTML with dynamic content can lead to XSS.',
|
|
159
|
+
regex: /dangerouslySetInnerHTML\s*=\s*\{\{?\s*__html\s*:/,
|
|
160
|
+
suggestion: 'Sanitize HTML content with DOMPurify before using dangerouslySetInnerHTML.',
|
|
161
|
+
cwe: 'CWE-79',
|
|
162
|
+
languages: ['javascript', 'typescript'],
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// Insecure Random
|
|
166
|
+
{
|
|
167
|
+
id: 'insecure-random',
|
|
168
|
+
severity: 'medium',
|
|
169
|
+
category: 'crypto',
|
|
170
|
+
title: 'Insecure Random Number Generator',
|
|
171
|
+
description: 'Math.random() is not cryptographically secure.',
|
|
172
|
+
regex: /Math\.random\s*\(\)/,
|
|
173
|
+
suggestion: 'Use crypto.getRandomValues() for security-sensitive operations.',
|
|
174
|
+
cwe: 'CWE-330',
|
|
175
|
+
languages: ['javascript', 'typescript'],
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// Path Traversal
|
|
179
|
+
{
|
|
180
|
+
id: 'path-traversal',
|
|
181
|
+
severity: 'high',
|
|
182
|
+
category: 'path-traversal',
|
|
183
|
+
title: 'Potential Path Traversal',
|
|
184
|
+
description: 'User input used in file path without sanitization.',
|
|
185
|
+
regex: /(?:readFile|readFileSync|open|writeFile|writeFileSync)\s*\(\s*(?:.*\+|.*\$\{)/,
|
|
186
|
+
suggestion: 'Validate and sanitize file paths. Use path.resolve() with a whitelist.',
|
|
187
|
+
cwe: 'CWE-22',
|
|
188
|
+
languages: ['javascript', 'typescript', 'python'],
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// Weak Cryptography
|
|
192
|
+
{
|
|
193
|
+
id: 'weak-hash-md5',
|
|
194
|
+
severity: 'medium',
|
|
195
|
+
category: 'crypto',
|
|
196
|
+
title: 'Weak Hashing Algorithm (MD5)',
|
|
197
|
+
description: 'MD5 is cryptographically broken. Use SHA-256 or better.',
|
|
198
|
+
regex: /(?:md5|MD5|hashlib\.md5)/,
|
|
199
|
+
suggestion: 'Use SHA-256 or SHA-3 for cryptographic hashing.',
|
|
200
|
+
cwe: 'CWE-328',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'weak-hash-sha1',
|
|
204
|
+
severity: 'medium',
|
|
205
|
+
category: 'crypto',
|
|
206
|
+
title: 'Weak Hashing Algorithm (SHA-1)',
|
|
207
|
+
description: 'SHA-1 is deprecated for cryptographic use. Use SHA-256 or better.',
|
|
208
|
+
regex: /(?:sha1|SHA1|hashlib\.sha1)/,
|
|
209
|
+
suggestion: 'Use SHA-256 or SHA-3 for cryptographic hashing.',
|
|
210
|
+
cwe: 'CWE-328',
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
// Debug/Console in Production
|
|
214
|
+
{
|
|
215
|
+
id: 'console-log',
|
|
216
|
+
severity: 'info',
|
|
217
|
+
category: 'best-practice',
|
|
218
|
+
title: 'Console Log Statement',
|
|
219
|
+
description: 'Console.log statements should be removed before production.',
|
|
220
|
+
regex: /console\.(log|debug|info|warn)\s*\(/,
|
|
221
|
+
suggestion: 'Use a proper logging framework and remove debug statements.',
|
|
222
|
+
languages: ['javascript', 'typescript'],
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'print-debug',
|
|
226
|
+
severity: 'info',
|
|
227
|
+
category: 'best-practice',
|
|
228
|
+
title: 'Print Debug Statement',
|
|
229
|
+
description: 'Print statements should be removed before production.',
|
|
230
|
+
regex: /print\s*\(\s*["'][^"']*["']\s*\)/,
|
|
231
|
+
suggestion: 'Use the logging module instead of print statements.',
|
|
232
|
+
languages: ['python'],
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// TODO/FIXME/HACK
|
|
236
|
+
{
|
|
237
|
+
id: 'todo-comment',
|
|
238
|
+
severity: 'info',
|
|
239
|
+
category: 'best-practice',
|
|
240
|
+
title: 'TODO Comment',
|
|
241
|
+
description: 'TODO comment found. Consider addressing this.',
|
|
242
|
+
regex: /\/\/\s*TODO|\/\*\s*TODO|#\s*TODO/i,
|
|
243
|
+
languages: ['javascript', 'typescript', 'python', 'go', 'java', 'rust'],
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Scanner
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
export class SecurityScanner {
|
|
252
|
+
private patterns: VulnerabilityPattern[]
|
|
253
|
+
|
|
254
|
+
constructor(customPatterns?: VulnerabilityPattern[]) {
|
|
255
|
+
this.patterns = customPatterns ?? VULNERABILITY_PATTERNS
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Scan a single file's content for security issues.
|
|
260
|
+
*/
|
|
261
|
+
scanFile(filePath: string, content: string, language?: string): SecurityFinding[] {
|
|
262
|
+
const findings: SecurityFinding[] = []
|
|
263
|
+
const lines = content.split('\n')
|
|
264
|
+
|
|
265
|
+
for (const pattern of this.patterns) {
|
|
266
|
+
// Skip if language filter doesn't match
|
|
267
|
+
if (pattern.languages && language && !pattern.languages.includes(language)) {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < lines.length; i++) {
|
|
272
|
+
const line = lines[i]
|
|
273
|
+
const match = line.match(pattern.regex)
|
|
274
|
+
if (match) {
|
|
275
|
+
findings.push({
|
|
276
|
+
id: `${pattern.id}-${filePath}:${i + 1}`,
|
|
277
|
+
severity: pattern.severity,
|
|
278
|
+
category: pattern.category,
|
|
279
|
+
title: pattern.title,
|
|
280
|
+
description: pattern.description,
|
|
281
|
+
file: filePath,
|
|
282
|
+
line: i + 1,
|
|
283
|
+
column: match.index,
|
|
284
|
+
code: line.trim(),
|
|
285
|
+
suggestion: pattern.suggestion,
|
|
286
|
+
cwe: pattern.cwe,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return findings
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Scan multiple files.
|
|
297
|
+
*/
|
|
298
|
+
scanFiles(
|
|
299
|
+
files: Array<{ path: string; content: string; language?: string }>
|
|
300
|
+
): SecurityReport {
|
|
301
|
+
const startTime = Date.now()
|
|
302
|
+
const allFindings: SecurityFinding[] = []
|
|
303
|
+
|
|
304
|
+
for (const file of files) {
|
|
305
|
+
const findings = this.scanFile(file.path, file.content, file.language)
|
|
306
|
+
allFindings.push(...findings)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const summary = {
|
|
310
|
+
total: allFindings.length,
|
|
311
|
+
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
312
|
+
high: allFindings.filter(f => f.severity === 'high').length,
|
|
313
|
+
medium: allFindings.filter(f => f.severity === 'medium').length,
|
|
314
|
+
low: allFindings.filter(f => f.severity === 'low').length,
|
|
315
|
+
info: allFindings.filter(f => f.severity === 'info').length,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
findings: allFindings.sort((a, b) => {
|
|
320
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }
|
|
321
|
+
return severityOrder[a.severity] - severityOrder[b.severity]
|
|
322
|
+
}),
|
|
323
|
+
summary,
|
|
324
|
+
scannedFiles: files.length,
|
|
325
|
+
scanDuration: Date.now() - startTime,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Add custom vulnerability patterns.
|
|
331
|
+
*/
|
|
332
|
+
addPattern(pattern: VulnerabilityPattern): void {
|
|
333
|
+
this.patterns.push(pattern)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get all available patterns.
|
|
338
|
+
*/
|
|
339
|
+
getPatterns(): VulnerabilityPattern[] {
|
|
340
|
+
return [...this.patterns]
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { writeFileAtomic } from './atomic-write.js'
|
|
5
|
+
|
|
6
|
+
export type ArtifactTransactionStatus = 'begin' | 'staged' | 'commit-ready'
|
|
7
|
+
|
|
8
|
+
export interface ArtifactWriteInput {
|
|
9
|
+
targetPath: string
|
|
10
|
+
content: string
|
|
11
|
+
encoding?: BufferEncoding
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ArtifactWriteJournal {
|
|
15
|
+
targetPath: string
|
|
16
|
+
stagedPath: string
|
|
17
|
+
encoding: BufferEncoding
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ArtifactTransactionJournal {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
createdAt: string
|
|
24
|
+
status: ArtifactTransactionStatus
|
|
25
|
+
writes: ArtifactWriteJournal[]
|
|
26
|
+
committedAt?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ArtifactTransactionOptions {
|
|
30
|
+
simulateCrashAt?: 'after-begin' | 'after-stage' | 'after-commit-marker'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RecoverySummary {
|
|
34
|
+
recovered: number
|
|
35
|
+
rolledBack: number
|
|
36
|
+
removedJournals: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getTransactionDirectory(projectRoot: string): string {
|
|
40
|
+
return path.join(projectRoot, '.mikk', 'transactions')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function writeJournal(journalPath: string, journal: ArtifactTransactionJournal): Promise<void> {
|
|
44
|
+
await writeFileAtomic(journalPath, JSON.stringify(journal, null, 2), { encoding: 'utf-8' })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeStagedPath(targetPath: string, id: string): string {
|
|
48
|
+
const dir = path.dirname(targetPath)
|
|
49
|
+
const base = path.basename(targetPath)
|
|
50
|
+
return path.join(dir, `.${base}.txn-${id}.staged`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runArtifactWriteTransaction(
|
|
54
|
+
projectRoot: string,
|
|
55
|
+
name: string,
|
|
56
|
+
writes: ArtifactWriteInput[],
|
|
57
|
+
options: ArtifactTransactionOptions = {},
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
if (writes.length === 0) return
|
|
60
|
+
|
|
61
|
+
const txDir = getTransactionDirectory(projectRoot)
|
|
62
|
+
await fs.mkdir(txDir, { recursive: true })
|
|
63
|
+
|
|
64
|
+
const id = `${Date.now()}-${randomUUID().slice(0, 8)}`
|
|
65
|
+
const journalPath = path.join(txDir, `${id}.journal.json`)
|
|
66
|
+
const journal: ArtifactTransactionJournal = {
|
|
67
|
+
id,
|
|
68
|
+
name,
|
|
69
|
+
createdAt: new Date().toISOString(),
|
|
70
|
+
status: 'begin',
|
|
71
|
+
writes: writes.map((w) => ({
|
|
72
|
+
targetPath: w.targetPath,
|
|
73
|
+
stagedPath: makeStagedPath(w.targetPath, id),
|
|
74
|
+
encoding: w.encoding ?? 'utf-8',
|
|
75
|
+
})),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await writeJournal(journalPath, journal)
|
|
79
|
+
if (options.simulateCrashAt === 'after-begin') {
|
|
80
|
+
throw new Error('Simulated crash after begin')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < writes.length; i++) {
|
|
84
|
+
const input = writes[i]
|
|
85
|
+
const record = journal.writes[i]
|
|
86
|
+
await fs.mkdir(path.dirname(record.stagedPath), { recursive: true })
|
|
87
|
+
await writeFileAtomic(record.stagedPath, input.content, { encoding: record.encoding })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
journal.status = 'staged'
|
|
91
|
+
await writeJournal(journalPath, journal)
|
|
92
|
+
if (options.simulateCrashAt === 'after-stage') {
|
|
93
|
+
throw new Error('Simulated crash after stage')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
journal.status = 'commit-ready'
|
|
97
|
+
journal.committedAt = new Date().toISOString()
|
|
98
|
+
await writeJournal(journalPath, journal)
|
|
99
|
+
if (options.simulateCrashAt === 'after-commit-marker') {
|
|
100
|
+
throw new Error('Simulated crash after commit marker')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const write of journal.writes) {
|
|
104
|
+
await fs.rename(write.stagedPath, write.targetPath)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await fs.unlink(journalPath)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function recoverArtifactWriteTransactions(projectRoot: string): Promise<RecoverySummary> {
|
|
111
|
+
const txDir = getTransactionDirectory(projectRoot)
|
|
112
|
+
const summary: RecoverySummary = {
|
|
113
|
+
recovered: 0,
|
|
114
|
+
rolledBack: 0,
|
|
115
|
+
removedJournals: 0,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let entries: string[] = []
|
|
119
|
+
try {
|
|
120
|
+
entries = await fs.readdir(txDir)
|
|
121
|
+
} catch {
|
|
122
|
+
return summary
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const journals = entries.filter((name) => name.endsWith('.journal.json'))
|
|
126
|
+
|
|
127
|
+
for (const file of journals) {
|
|
128
|
+
const journalPath = path.join(txDir, file)
|
|
129
|
+
let journal: ArtifactTransactionJournal | null = null
|
|
130
|
+
try {
|
|
131
|
+
const raw = await fs.readFile(journalPath, 'utf-8')
|
|
132
|
+
journal = JSON.parse(raw) as ArtifactTransactionJournal
|
|
133
|
+
} catch {
|
|
134
|
+
try {
|
|
135
|
+
await fs.unlink(journalPath)
|
|
136
|
+
summary.removedJournals += 1
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore broken journal cleanup failures.
|
|
139
|
+
}
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
if (journal.status === 'commit-ready') {
|
|
145
|
+
for (const write of journal.writes) {
|
|
146
|
+
try {
|
|
147
|
+
await fs.rename(write.stagedPath, write.targetPath)
|
|
148
|
+
} catch (err: any) {
|
|
149
|
+
if (err?.code !== 'ENOENT') {
|
|
150
|
+
throw err
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
summary.recovered += 1
|
|
155
|
+
} else {
|
|
156
|
+
for (const write of journal.writes) {
|
|
157
|
+
try {
|
|
158
|
+
await fs.unlink(write.stagedPath)
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore missing staged files.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
summary.rolledBack += 1
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
await fs.unlink(journalPath)
|
|
168
|
+
summary.removedJournals += 1
|
|
169
|
+
} catch {
|
|
170
|
+
// Ignore missing journal file.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return summary
|
|
176
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export interface AtomicWriteOptions {
|
|
6
|
+
encoding?: BufferEncoding
|
|
7
|
+
mode?: number
|
|
8
|
+
lockTimeoutMs?: number
|
|
9
|
+
staleLockMs?: number
|
|
10
|
+
retryDelayMs?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 10_000
|
|
14
|
+
const DEFAULT_STALE_LOCK_MS = 60_000
|
|
15
|
+
const DEFAULT_RETRY_DELAY_MS = 50
|
|
16
|
+
|
|
17
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
18
|
+
|
|
19
|
+
async function acquireFileLock(lockPath: string, options: AtomicWriteOptions): Promise<() => Promise<void>> {
|
|
20
|
+
const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
|
|
21
|
+
const staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS
|
|
22
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
|
|
23
|
+
const startedAt = Date.now()
|
|
24
|
+
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const fd = await fs.open(lockPath, 'wx')
|
|
28
|
+
try {
|
|
29
|
+
const payload = JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() })
|
|
30
|
+
await fd.writeFile(payload, { encoding: 'utf-8' })
|
|
31
|
+
await fd.sync()
|
|
32
|
+
} finally {
|
|
33
|
+
await fd.close()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return async () => {
|
|
37
|
+
try {
|
|
38
|
+
await fs.unlink(lockPath)
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore missing locks on release.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
if (err?.code !== 'EEXIST') {
|
|
45
|
+
throw err
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stat = await fs.stat(lockPath)
|
|
50
|
+
const ageMs = Date.now() - stat.mtimeMs
|
|
51
|
+
if (ageMs > staleLockMs) {
|
|
52
|
+
await fs.unlink(lockPath)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Lock disappeared between stat/unlink checks — retry acquisition.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (Date.now() - startedAt > lockTimeoutMs) {
|
|
60
|
+
throw new Error(`Timed out acquiring write lock for ${path.basename(lockPath)}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await sleep(retryDelayMs)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write a file atomically with a lock-file critical section.
|
|
70
|
+
*
|
|
71
|
+
* Guarantees:
|
|
72
|
+
* - Atomicity: write to temp file then rename
|
|
73
|
+
* - Isolation: one writer at a time via lock file
|
|
74
|
+
* - Durability: fsync temp file and parent directory (best effort)
|
|
75
|
+
*/
|
|
76
|
+
export async function writeFileAtomic(
|
|
77
|
+
targetPath: string,
|
|
78
|
+
content: string,
|
|
79
|
+
options: AtomicWriteOptions = {}
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const directory = path.dirname(targetPath)
|
|
82
|
+
const baseName = path.basename(targetPath)
|
|
83
|
+
const lockPath = `${targetPath}.lock`
|
|
84
|
+
const tempPath = path.join(
|
|
85
|
+
directory,
|
|
86
|
+
`.${baseName}.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}.tmp`
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await fs.mkdir(directory, { recursive: true })
|
|
90
|
+
const releaseLock = await acquireFileLock(lockPath, options)
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const fd = await fs.open(tempPath, 'w', options.mode)
|
|
94
|
+
try {
|
|
95
|
+
await fd.writeFile(content, { encoding: options.encoding ?? 'utf-8' })
|
|
96
|
+
await fd.sync()
|
|
97
|
+
} finally {
|
|
98
|
+
await fd.close()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await fs.rename(tempPath, targetPath)
|
|
102
|
+
|
|
103
|
+
// Best-effort directory fsync for rename durability.
|
|
104
|
+
try {
|
|
105
|
+
const dirFd = await fs.open(directory, 'r')
|
|
106
|
+
try {
|
|
107
|
+
await dirFd.sync()
|
|
108
|
+
} finally {
|
|
109
|
+
await dirFd.close()
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Directory fsync can fail on some platforms/filesystems.
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
try {
|
|
116
|
+
await fs.unlink(tempPath)
|
|
117
|
+
} catch {
|
|
118
|
+
// Temp file may already be moved/removed.
|
|
119
|
+
}
|
|
120
|
+
await releaseLock()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function writeJsonAtomic(
|
|
125
|
+
targetPath: string,
|
|
126
|
+
value: unknown,
|
|
127
|
+
options: AtomicWriteOptions = {}
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const payload = JSON.stringify(value)
|
|
130
|
+
await writeFileAtomic(targetPath, payload, options)
|
|
131
|
+
}
|