agileflow 2.89.1 → 2.89.3
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/CHANGELOG.md +10 -0
- package/lib/content-sanitizer.js +463 -0
- package/lib/error-codes.js +544 -0
- package/lib/errors.js +336 -5
- package/lib/feedback.js +561 -0
- package/lib/path-resolver.js +396 -0
- package/lib/session-registry.js +461 -0
- package/lib/smart-json-file.js +449 -0
- package/lib/validate.js +165 -11
- package/package.json +1 -1
- package/scripts/agileflow-configure.js +40 -1440
- package/scripts/agileflow-welcome.js +2 -1
- package/scripts/lib/configure-detect.js +383 -0
- package/scripts/lib/configure-features.js +811 -0
- package/scripts/lib/configure-repair.js +314 -0
- package/scripts/lib/configure-utils.js +115 -0
- package/scripts/lib/frontmatter-parser.js +3 -3
- package/scripts/obtain-context.js +417 -113
- package/scripts/ralph-loop.js +1 -1
- package/tools/cli/commands/config.js +3 -3
- package/tools/cli/commands/doctor.js +30 -2
- package/tools/cli/commands/list.js +2 -2
- package/tools/cli/commands/uninstall.js +3 -3
- package/tools/cli/installers/core/installer.js +62 -12
- package/tools/cli/installers/ide/_interface.js +238 -0
- package/tools/cli/installers/ide/codex.js +2 -2
- package/tools/cli/installers/ide/manager.js +15 -0
- package/tools/cli/lib/content-injector.js +69 -16
- package/tools/cli/lib/ide-errors.js +163 -29
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smart-json-file.js - Safe JSON File Operations with Retry Logic
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic writes, automatic retries, and error code integration
|
|
5
|
+
* for all JSON file operations in AgileFlow.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Atomic writes (write to temp file, then rename)
|
|
9
|
+
* - Configurable retry logic with exponential backoff
|
|
10
|
+
* - Integration with error-codes.js for typed errors
|
|
11
|
+
* - Schema validation support
|
|
12
|
+
* - Automatic directory creation
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* const SmartJsonFile = require('./smart-json-file');
|
|
16
|
+
*
|
|
17
|
+
* // Basic usage
|
|
18
|
+
* const file = new SmartJsonFile('/path/to/file.json');
|
|
19
|
+
* const data = await file.read();
|
|
20
|
+
* await file.write({ key: 'value' });
|
|
21
|
+
*
|
|
22
|
+
* // With options
|
|
23
|
+
* const file = new SmartJsonFile('/path/to/file.json', {
|
|
24
|
+
* retries: 3,
|
|
25
|
+
* backoff: 100,
|
|
26
|
+
* createDir: true,
|
|
27
|
+
* schema: mySchema
|
|
28
|
+
* });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { createTypedError, getErrorCodeFromError, ErrorCodes } = require('./error-codes');
|
|
34
|
+
|
|
35
|
+
// Debug logging
|
|
36
|
+
const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
|
|
37
|
+
|
|
38
|
+
function debugLog(operation, details) {
|
|
39
|
+
if (DEBUG) {
|
|
40
|
+
const timestamp = new Date().toISOString();
|
|
41
|
+
console.error(`[${timestamp}] [SmartJsonFile] ${operation}:`, JSON.stringify(details));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate a unique temporary file path
|
|
47
|
+
* @param {string} filePath - Original file path
|
|
48
|
+
* @returns {string} Temporary file path
|
|
49
|
+
*/
|
|
50
|
+
function getTempPath(filePath) {
|
|
51
|
+
const dir = path.dirname(filePath);
|
|
52
|
+
const ext = path.extname(filePath);
|
|
53
|
+
const base = path.basename(filePath, ext);
|
|
54
|
+
const timestamp = Date.now();
|
|
55
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
56
|
+
return path.join(dir, `.${base}.${timestamp}.${random}${ext}.tmp`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sleep for a specified duration
|
|
61
|
+
* @param {number} ms - Milliseconds to sleep
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* SmartJsonFile - Safe JSON file operations with retry logic
|
|
70
|
+
*/
|
|
71
|
+
class SmartJsonFile {
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} filePath - Absolute path to JSON file
|
|
74
|
+
* @param {Object} [options={}] - Configuration options
|
|
75
|
+
* @param {number} [options.retries=3] - Number of retry attempts
|
|
76
|
+
* @param {number} [options.backoff=100] - Initial backoff in ms (doubles each retry)
|
|
77
|
+
* @param {boolean} [options.createDir=true] - Create parent directory if missing
|
|
78
|
+
* @param {number} [options.spaces=2] - JSON indentation spaces
|
|
79
|
+
* @param {Function} [options.schema] - Optional validation function (throws on invalid)
|
|
80
|
+
* @param {*} [options.defaultValue] - Default value if file doesn't exist
|
|
81
|
+
*/
|
|
82
|
+
constructor(filePath, options = {}) {
|
|
83
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
84
|
+
throw createTypedError('filePath is required and must be a string', 'EINVAL', {
|
|
85
|
+
context: { provided: typeof filePath },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!path.isAbsolute(filePath)) {
|
|
90
|
+
throw createTypedError('filePath must be an absolute path', 'EINVAL', {
|
|
91
|
+
context: { provided: filePath },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.filePath = filePath;
|
|
96
|
+
this.retries = options.retries ?? 3;
|
|
97
|
+
this.backoff = options.backoff ?? 100;
|
|
98
|
+
this.createDir = options.createDir ?? true;
|
|
99
|
+
this.spaces = options.spaces ?? 2;
|
|
100
|
+
this.schema = options.schema ?? null;
|
|
101
|
+
this.defaultValue = options.defaultValue;
|
|
102
|
+
|
|
103
|
+
debugLog('constructor', {
|
|
104
|
+
filePath,
|
|
105
|
+
options: { retries: this.retries, backoff: this.backoff },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read and parse JSON file
|
|
111
|
+
* @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
|
|
112
|
+
*/
|
|
113
|
+
async read() {
|
|
114
|
+
let lastError = null;
|
|
115
|
+
let attempt = 0;
|
|
116
|
+
|
|
117
|
+
while (attempt <= this.retries) {
|
|
118
|
+
try {
|
|
119
|
+
debugLog('read', { filePath: this.filePath, attempt });
|
|
120
|
+
|
|
121
|
+
// Check if file exists
|
|
122
|
+
if (!fs.existsSync(this.filePath)) {
|
|
123
|
+
if (this.defaultValue !== undefined) {
|
|
124
|
+
debugLog('read', { status: 'using default value' });
|
|
125
|
+
return { ok: true, data: this.defaultValue };
|
|
126
|
+
}
|
|
127
|
+
const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
|
|
128
|
+
context: { filePath: this.filePath },
|
|
129
|
+
});
|
|
130
|
+
return { ok: false, error };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read file
|
|
134
|
+
const content = fs.readFileSync(this.filePath, 'utf8');
|
|
135
|
+
|
|
136
|
+
// Parse JSON
|
|
137
|
+
let data;
|
|
138
|
+
try {
|
|
139
|
+
data = JSON.parse(content);
|
|
140
|
+
} catch (parseError) {
|
|
141
|
+
const error = createTypedError(
|
|
142
|
+
`Invalid JSON in ${this.filePath}: ${parseError.message}`,
|
|
143
|
+
'EPARSE',
|
|
144
|
+
{ cause: parseError, context: { filePath: this.filePath } }
|
|
145
|
+
);
|
|
146
|
+
return { ok: false, error };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Validate schema if provided
|
|
150
|
+
if (this.schema) {
|
|
151
|
+
try {
|
|
152
|
+
this.schema(data);
|
|
153
|
+
} catch (schemaError) {
|
|
154
|
+
const error = createTypedError(
|
|
155
|
+
`Schema validation failed for ${this.filePath}: ${schemaError.message}`,
|
|
156
|
+
'ESCHEMA',
|
|
157
|
+
{ cause: schemaError, context: { filePath: this.filePath } }
|
|
158
|
+
);
|
|
159
|
+
return { ok: false, error };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
debugLog('read', { status: 'success' });
|
|
164
|
+
return { ok: true, data };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
lastError = err;
|
|
167
|
+
debugLog('read', { status: 'error', error: err.message, attempt });
|
|
168
|
+
|
|
169
|
+
// Don't retry for certain errors
|
|
170
|
+
const errorCode = getErrorCodeFromError(err);
|
|
171
|
+
if (errorCode.code === 'EACCES' || errorCode.code === 'EPERM') {
|
|
172
|
+
// Permission errors won't resolve with retries
|
|
173
|
+
const error = createTypedError(`Permission denied reading ${this.filePath}`, 'EACCES', {
|
|
174
|
+
cause: err,
|
|
175
|
+
context: { filePath: this.filePath },
|
|
176
|
+
});
|
|
177
|
+
return { ok: false, error };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Wait before retrying
|
|
181
|
+
if (attempt < this.retries) {
|
|
182
|
+
const waitTime = this.backoff * Math.pow(2, attempt);
|
|
183
|
+
debugLog('read', { status: 'retrying', waitTime });
|
|
184
|
+
await sleep(waitTime);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
attempt++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// All retries exhausted
|
|
192
|
+
const error = createTypedError(
|
|
193
|
+
`Failed to read ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
|
|
194
|
+
'EUNKNOWN',
|
|
195
|
+
{ cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
|
|
196
|
+
);
|
|
197
|
+
return { ok: false, error };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Write data to JSON file atomically
|
|
202
|
+
* @param {any} data - Data to write
|
|
203
|
+
* @returns {Promise<{ok: boolean, error?: Error}>} Result object
|
|
204
|
+
*/
|
|
205
|
+
async write(data) {
|
|
206
|
+
let lastError = null;
|
|
207
|
+
let attempt = 0;
|
|
208
|
+
|
|
209
|
+
// Validate schema before writing
|
|
210
|
+
if (this.schema) {
|
|
211
|
+
try {
|
|
212
|
+
this.schema(data);
|
|
213
|
+
} catch (schemaError) {
|
|
214
|
+
const error = createTypedError(
|
|
215
|
+
`Schema validation failed: ${schemaError.message}`,
|
|
216
|
+
'ESCHEMA',
|
|
217
|
+
{ cause: schemaError, context: { filePath: this.filePath } }
|
|
218
|
+
);
|
|
219
|
+
return { ok: false, error };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
while (attempt <= this.retries) {
|
|
224
|
+
const tempPath = getTempPath(this.filePath);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
debugLog('write', { filePath: this.filePath, tempPath, attempt });
|
|
228
|
+
|
|
229
|
+
// Create parent directory if needed
|
|
230
|
+
const dir = path.dirname(this.filePath);
|
|
231
|
+
if (this.createDir && !fs.existsSync(dir)) {
|
|
232
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
233
|
+
debugLog('write', { status: 'created directory', dir });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Serialize data
|
|
237
|
+
const content = JSON.stringify(data, null, this.spaces) + '\n';
|
|
238
|
+
|
|
239
|
+
// Write to temp file
|
|
240
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
241
|
+
debugLog('write', { status: 'wrote temp file' });
|
|
242
|
+
|
|
243
|
+
// Atomic rename
|
|
244
|
+
fs.renameSync(tempPath, this.filePath);
|
|
245
|
+
debugLog('write', { status: 'success' });
|
|
246
|
+
|
|
247
|
+
return { ok: true };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
lastError = err;
|
|
250
|
+
debugLog('write', { status: 'error', error: err.message, attempt });
|
|
251
|
+
|
|
252
|
+
// Clean up temp file if it exists
|
|
253
|
+
try {
|
|
254
|
+
if (fs.existsSync(tempPath)) {
|
|
255
|
+
fs.unlinkSync(tempPath);
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// Ignore cleanup errors
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Don't retry for certain errors
|
|
262
|
+
const errorCode = getErrorCodeFromError(err);
|
|
263
|
+
if (
|
|
264
|
+
errorCode.code === 'EACCES' ||
|
|
265
|
+
errorCode.code === 'EPERM' ||
|
|
266
|
+
errorCode.code === 'EROFS'
|
|
267
|
+
) {
|
|
268
|
+
const error = createTypedError(
|
|
269
|
+
`Permission denied writing ${this.filePath}`,
|
|
270
|
+
errorCode.code,
|
|
271
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
272
|
+
);
|
|
273
|
+
return { ok: false, error };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Wait before retrying
|
|
277
|
+
if (attempt < this.retries) {
|
|
278
|
+
const waitTime = this.backoff * Math.pow(2, attempt);
|
|
279
|
+
debugLog('write', { status: 'retrying', waitTime });
|
|
280
|
+
await sleep(waitTime);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
attempt++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// All retries exhausted
|
|
288
|
+
const error = createTypedError(
|
|
289
|
+
`Failed to write ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
|
|
290
|
+
'EUNKNOWN',
|
|
291
|
+
{ cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
|
|
292
|
+
);
|
|
293
|
+
return { ok: false, error };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Read, modify, and write atomically
|
|
298
|
+
* @param {Function} modifier - Function that takes current data and returns modified data
|
|
299
|
+
* @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
|
|
300
|
+
*/
|
|
301
|
+
async modify(modifier) {
|
|
302
|
+
debugLog('modify', { filePath: this.filePath });
|
|
303
|
+
|
|
304
|
+
// Read current data
|
|
305
|
+
const readResult = await this.read();
|
|
306
|
+
if (!readResult.ok) {
|
|
307
|
+
// If file doesn't exist but we have a default value, use that
|
|
308
|
+
if (readResult.error?.errorCode === 'ENOENT' && this.defaultValue !== undefined) {
|
|
309
|
+
readResult.ok = true;
|
|
310
|
+
readResult.data = this.defaultValue;
|
|
311
|
+
delete readResult.error;
|
|
312
|
+
} else {
|
|
313
|
+
return readResult;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Apply modifier
|
|
318
|
+
let newData;
|
|
319
|
+
try {
|
|
320
|
+
newData = await modifier(readResult.data);
|
|
321
|
+
} catch (modifyError) {
|
|
322
|
+
const error = createTypedError(`Modifier function failed: ${modifyError.message}`, 'EINVAL', {
|
|
323
|
+
cause: modifyError,
|
|
324
|
+
context: { filePath: this.filePath },
|
|
325
|
+
});
|
|
326
|
+
return { ok: false, error };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Write modified data
|
|
330
|
+
const writeResult = await this.write(newData);
|
|
331
|
+
if (!writeResult.ok) {
|
|
332
|
+
return writeResult;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { ok: true, data: newData };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check if file exists
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
exists() {
|
|
343
|
+
return fs.existsSync(this.filePath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Delete the file
|
|
348
|
+
* @returns {Promise<{ok: boolean, error?: Error}>}
|
|
349
|
+
*/
|
|
350
|
+
async delete() {
|
|
351
|
+
try {
|
|
352
|
+
if (!fs.existsSync(this.filePath)) {
|
|
353
|
+
return { ok: true }; // Already doesn't exist
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fs.unlinkSync(this.filePath);
|
|
357
|
+
debugLog('delete', { filePath: this.filePath, status: 'success' });
|
|
358
|
+
return { ok: true };
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const errorCode = getErrorCodeFromError(err);
|
|
361
|
+
const error = createTypedError(
|
|
362
|
+
`Failed to delete ${this.filePath}: ${err.message}`,
|
|
363
|
+
errorCode.code,
|
|
364
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
365
|
+
);
|
|
366
|
+
return { ok: false, error };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Synchronous read - for cases where async isn't possible
|
|
372
|
+
* @returns {{ok: boolean, data?: any, error?: Error}}
|
|
373
|
+
*/
|
|
374
|
+
readSync() {
|
|
375
|
+
try {
|
|
376
|
+
if (!fs.existsSync(this.filePath)) {
|
|
377
|
+
if (this.defaultValue !== undefined) {
|
|
378
|
+
return { ok: true, data: this.defaultValue };
|
|
379
|
+
}
|
|
380
|
+
const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
|
|
381
|
+
context: { filePath: this.filePath },
|
|
382
|
+
});
|
|
383
|
+
return { ok: false, error };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const content = fs.readFileSync(this.filePath, 'utf8');
|
|
387
|
+
const data = JSON.parse(content);
|
|
388
|
+
|
|
389
|
+
if (this.schema) {
|
|
390
|
+
this.schema(data);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { ok: true, data };
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const errorCode = getErrorCodeFromError(err);
|
|
396
|
+
const error = createTypedError(
|
|
397
|
+
`Failed to read ${this.filePath}: ${err.message}`,
|
|
398
|
+
errorCode.code,
|
|
399
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
400
|
+
);
|
|
401
|
+
return { ok: false, error };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Synchronous write - for cases where async isn't possible
|
|
407
|
+
* @param {any} data - Data to write
|
|
408
|
+
* @returns {{ok: boolean, error?: Error}}
|
|
409
|
+
*/
|
|
410
|
+
writeSync(data) {
|
|
411
|
+
const tempPath = getTempPath(this.filePath);
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
if (this.schema) {
|
|
415
|
+
this.schema(data);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const dir = path.dirname(this.filePath);
|
|
419
|
+
if (this.createDir && !fs.existsSync(dir)) {
|
|
420
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const content = JSON.stringify(data, null, this.spaces) + '\n';
|
|
424
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
425
|
+
fs.renameSync(tempPath, this.filePath);
|
|
426
|
+
|
|
427
|
+
return { ok: true };
|
|
428
|
+
} catch (err) {
|
|
429
|
+
// Clean up temp file
|
|
430
|
+
try {
|
|
431
|
+
if (fs.existsSync(tempPath)) {
|
|
432
|
+
fs.unlinkSync(tempPath);
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
// Ignore
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const errorCode = getErrorCodeFromError(err);
|
|
439
|
+
const error = createTypedError(
|
|
440
|
+
`Failed to write ${this.filePath}: ${err.message}`,
|
|
441
|
+
errorCode.code,
|
|
442
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
443
|
+
);
|
|
444
|
+
return { ok: false, error };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
module.exports = SmartJsonFile;
|
package/lib/validate.js
CHANGED
|
@@ -345,19 +345,87 @@ class PathValidationError extends Error {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Check the depth of a symlink chain (how many symlinks to follow to reach final target).
|
|
350
|
+
* Returns early if chain exceeds maxDepth to prevent infinite loops from circular symlinks.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} filePath - Starting path to check
|
|
353
|
+
* @param {number} maxDepth - Maximum allowed symlink chain depth
|
|
354
|
+
* @returns {{ ok: boolean, depth: number, error?: string, isCircular?: boolean }}
|
|
355
|
+
*/
|
|
356
|
+
function checkSymlinkChainDepth(filePath, maxDepth) {
|
|
357
|
+
let current = filePath;
|
|
358
|
+
let depth = 0;
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
|
|
361
|
+
// Loop until we find a non-symlink or exceed max depth
|
|
362
|
+
while (true) {
|
|
363
|
+
// Check for circular symlinks
|
|
364
|
+
if (seen.has(current)) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
depth,
|
|
368
|
+
error: `Circular symlink detected at: ${current}`,
|
|
369
|
+
isCircular: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
seen.add(current);
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const stats = fs.lstatSync(current);
|
|
376
|
+
if (!stats.isSymbolicLink()) {
|
|
377
|
+
// Reached a real file/directory, chain ends
|
|
378
|
+
return { ok: true, depth };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Increment depth before checking limit
|
|
382
|
+
depth++;
|
|
383
|
+
|
|
384
|
+
// Check if we've exceeded max depth
|
|
385
|
+
if (depth > maxDepth) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
depth,
|
|
389
|
+
error: `Symlink chain depth (${depth}) exceeds maximum (${maxDepth})`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Read symlink target
|
|
394
|
+
const target = fs.readlinkSync(current);
|
|
395
|
+
|
|
396
|
+
// Resolve target path (could be relative or absolute)
|
|
397
|
+
if (path.isAbsolute(target)) {
|
|
398
|
+
current = target;
|
|
399
|
+
} else {
|
|
400
|
+
current = path.resolve(path.dirname(current), target);
|
|
401
|
+
}
|
|
402
|
+
} catch (e) {
|
|
403
|
+
if (e.code === 'ENOENT') {
|
|
404
|
+
// Path doesn't exist, chain ends here
|
|
405
|
+
return { ok: true, depth };
|
|
406
|
+
}
|
|
407
|
+
// Other error (permission denied, etc.)
|
|
408
|
+
return { ok: true, depth };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
348
413
|
/**
|
|
349
414
|
* Validate that a path is safe and within the allowed base directory.
|
|
350
415
|
* Prevents path traversal attacks by:
|
|
351
416
|
* 1. Resolving the path to absolute form
|
|
352
417
|
* 2. Ensuring it stays within the base directory
|
|
353
418
|
* 3. Rejecting symbolic links (optional)
|
|
419
|
+
* 4. When symlinks allowed, verifying symlink targets stay within base
|
|
420
|
+
* 5. Limiting symlink chain depth to prevent infinite loops
|
|
354
421
|
*
|
|
355
422
|
* @param {string} inputPath - The path to validate (can be relative or absolute)
|
|
356
423
|
* @param {string} baseDir - The allowed base directory (must be absolute)
|
|
357
424
|
* @param {Object} options - Validation options
|
|
358
425
|
* @param {boolean} [options.allowSymlinks=false] - Allow symbolic links
|
|
359
426
|
* @param {boolean} [options.mustExist=false] - Path must exist on filesystem
|
|
360
|
-
* @
|
|
427
|
+
* @param {number} [options.maxSymlinkDepth=3] - Maximum symlink chain depth (when symlinks allowed)
|
|
428
|
+
* @returns {{ ok: boolean, resolvedPath?: string, realPath?: string, error?: PathValidationError }}
|
|
361
429
|
*
|
|
362
430
|
* @example
|
|
363
431
|
* // Validate a file path within project directory
|
|
@@ -371,9 +439,14 @@ class PathValidationError extends Error {
|
|
|
371
439
|
* const result = validatePath('../../../etc/passwd', '/home/user/project');
|
|
372
440
|
* // result.ok === false
|
|
373
441
|
* // result.error.reason === 'path_traversal'
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* // Reject deep symlink chains
|
|
445
|
+
* const result = validatePath('link1', baseDir, { allowSymlinks: true, maxSymlinkDepth: 3 });
|
|
446
|
+
* // If link1 -> link2 -> link3 -> link4 -> target, this fails with 'symlink_chain_too_deep'
|
|
374
447
|
*/
|
|
375
448
|
function validatePath(inputPath, baseDir, options = {}) {
|
|
376
|
-
const { allowSymlinks = false, mustExist = false } = options;
|
|
449
|
+
const { allowSymlinks = false, mustExist = false, maxSymlinkDepth = 3 } = options;
|
|
377
450
|
|
|
378
451
|
// Input validation
|
|
379
452
|
if (!inputPath || typeof inputPath !== 'string') {
|
|
@@ -421,15 +494,16 @@ function validatePath(inputPath, baseDir, options = {}) {
|
|
|
421
494
|
resolvedPath = path.resolve(normalizedBase, inputPath);
|
|
422
495
|
}
|
|
423
496
|
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
497
|
+
// Helper function to check if path is within base
|
|
498
|
+
const checkWithinBase = pathToCheck => {
|
|
499
|
+
const baseWithSep = normalizedBase.endsWith(path.sep)
|
|
500
|
+
? normalizedBase
|
|
501
|
+
: normalizedBase + path.sep;
|
|
502
|
+
return pathToCheck === normalizedBase || pathToCheck.startsWith(baseWithSep);
|
|
503
|
+
};
|
|
431
504
|
|
|
432
|
-
|
|
505
|
+
// Check for path traversal: resolved path must start with base directory
|
|
506
|
+
if (!checkWithinBase(resolvedPath)) {
|
|
433
507
|
return {
|
|
434
508
|
ok: false,
|
|
435
509
|
error: new PathValidationError(
|
|
@@ -456,8 +530,9 @@ function validatePath(inputPath, baseDir, options = {}) {
|
|
|
456
530
|
}
|
|
457
531
|
}
|
|
458
532
|
|
|
459
|
-
// Check for symbolic links
|
|
533
|
+
// Check for symbolic links
|
|
460
534
|
if (!allowSymlinks) {
|
|
535
|
+
// Symlinks not allowed - reject if found
|
|
461
536
|
try {
|
|
462
537
|
const stats = fs.lstatSync(resolvedPath);
|
|
463
538
|
if (stats.isSymbolicLink()) {
|
|
@@ -496,6 +571,84 @@ function validatePath(inputPath, baseDir, options = {}) {
|
|
|
496
571
|
}
|
|
497
572
|
}
|
|
498
573
|
}
|
|
574
|
+
} else {
|
|
575
|
+
// Symlinks allowed - but we must verify the target stays within base!
|
|
576
|
+
// This prevents symlink-based escape attacks
|
|
577
|
+
try {
|
|
578
|
+
const stats = fs.lstatSync(resolvedPath);
|
|
579
|
+
if (stats.isSymbolicLink()) {
|
|
580
|
+
// Check symlink chain depth to prevent DoS via infinite loops
|
|
581
|
+
const chainResult = checkSymlinkChainDepth(resolvedPath, maxSymlinkDepth);
|
|
582
|
+
if (!chainResult.ok) {
|
|
583
|
+
const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
error: new PathValidationError(chainResult.error, inputPath, reason),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Resolve the symlink target to its real path
|
|
591
|
+
const realPath = fs.realpathSync(resolvedPath);
|
|
592
|
+
|
|
593
|
+
// Verify the real path is also within base directory
|
|
594
|
+
if (!checkWithinBase(realPath)) {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
error: new PathValidationError(
|
|
598
|
+
`Symlink target escapes base directory: ${inputPath} -> ${realPath}`,
|
|
599
|
+
inputPath,
|
|
600
|
+
'symlink_escape'
|
|
601
|
+
),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Return both the resolved path and the real path
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
resolvedPath,
|
|
609
|
+
realPath,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
// Path doesn't exist - that's okay for non-mustExist scenarios
|
|
614
|
+
// Also check parent directories for symlinks that might escape
|
|
615
|
+
const parts = path.relative(normalizedBase, resolvedPath).split(path.sep);
|
|
616
|
+
let currentPath = normalizedBase;
|
|
617
|
+
|
|
618
|
+
for (const part of parts) {
|
|
619
|
+
currentPath = path.join(currentPath, part);
|
|
620
|
+
try {
|
|
621
|
+
const stats = fs.lstatSync(currentPath);
|
|
622
|
+
if (stats.isSymbolicLink()) {
|
|
623
|
+
// Check symlink chain depth
|
|
624
|
+
const chainResult = checkSymlinkChainDepth(currentPath, maxSymlinkDepth);
|
|
625
|
+
if (!chainResult.ok) {
|
|
626
|
+
const reason = chainResult.isCircular ? 'symlink_circular' : 'symlink_chain_too_deep';
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
error: new PathValidationError(chainResult.error, inputPath, reason),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Resolve this symlink and check its target
|
|
634
|
+
const realPath = fs.realpathSync(currentPath);
|
|
635
|
+
if (!checkWithinBase(realPath)) {
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
error: new PathValidationError(
|
|
639
|
+
`Path contains symlink escaping base: ${currentPath} -> ${realPath}`,
|
|
640
|
+
inputPath,
|
|
641
|
+
'symlink_escape'
|
|
642
|
+
),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} catch {
|
|
647
|
+
// Part of path doesn't exist, stop checking
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
499
652
|
}
|
|
500
653
|
|
|
501
654
|
return {
|
|
@@ -613,4 +766,5 @@ module.exports = {
|
|
|
613
766
|
validatePathSync,
|
|
614
767
|
hasUnsafePathPatterns,
|
|
615
768
|
sanitizeFilename,
|
|
769
|
+
checkSymlinkChainDepth,
|
|
616
770
|
};
|