filemayor 2.1.0 → 4.0.5

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/core/security.js CHANGED
@@ -1,317 +1,370 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR CORE — SECURITY
6
- * Path traversal guards, input sanitization, and safety checks
7
- * ═══════════════════════════════════════════════════════════════════
8
- */
9
-
10
- 'use strict';
11
-
12
- const path = require('path');
13
- const fs = require('fs');
14
- const os = require('os');
15
-
16
- // ─── Dangerous Paths ──────────────────────────────────────────────
17
- const PROTECTED_DIRECTORIES = new Set([
18
- // System roots
19
- '/', 'C:\\', 'C:\\Windows', 'C:\\Windows\\System32',
20
- '/System', '/Library', '/usr', '/bin', '/sbin', '/etc',
21
- '/boot', '/dev', '/proc', '/sys', '/run', '/var',
22
-
23
- // User critical
24
- path.join(os.homedir()), // Don't organize the entire home dir
25
- ]);
26
-
27
- const PROTECTED_PREFIXES = [
28
- '/System', '/Library/System',
29
- 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
30
- 'C:\\ProgramData', '/usr/lib', '/usr/bin', '/usr/sbin',
31
- '/var/lib', '/var/run', '/boot', '/dev', '/proc', '/sys'
32
- ];
33
-
34
- const DANGEROUS_EXTENSIONS = new Set([
35
- '.sys', '.drv', '.dll', '.so', '.dylib', '.kext',
36
- '.plist', '.reg', '.msc', '.cpl'
37
- ]);
38
-
39
- // ─── Path Validation ──────────────────────────────────────────────
40
-
41
- /**
42
- * Resolve and normalize a path, preventing traversal attacks
43
- * @param {string} inputPath - Raw path from user input
44
- * @param {string} [basePath] - Optional base to restrict resolution within
45
- * @returns {{ valid: boolean, resolved: string, error?: string }}
46
- */
47
- function validatePath(inputPath, basePath = null) {
48
- if (!inputPath || typeof inputPath !== 'string') {
49
- return { valid: false, resolved: '', error: 'Path is empty or invalid' };
50
- }
51
-
52
- // Reject null bytes (path traversal vector)
53
- if (inputPath.includes('\0')) {
54
- return { valid: false, resolved: '', error: 'Path contains null bytes (rejected)' };
55
- }
56
-
57
- // Resolve to absolute
58
- const resolved = path.resolve(inputPath);
59
-
60
- // If a basePath is given, ensure resolved path stays within it
61
- if (basePath) {
62
- const resolvedBase = path.resolve(basePath);
63
- if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
64
- return {
65
- valid: false,
66
- resolved,
67
- error: `Path escapes base directory: "${resolved}" is outside "${resolvedBase}"`
68
- };
69
- }
70
- }
71
-
72
- // Check against protected directories
73
- if (PROTECTED_DIRECTORIES.has(resolved)) {
74
- return {
75
- valid: false,
76
- resolved,
77
- error: `Refusing to operate on protected directory: "${resolved}"`
78
- };
79
- }
80
-
81
- // Check against protected prefixes
82
- for (const prefix of PROTECTED_PREFIXES) {
83
- const normalizedPrefix = path.resolve(prefix);
84
- if (resolved.startsWith(normalizedPrefix + path.sep) || resolved === normalizedPrefix) {
85
- return {
86
- valid: false,
87
- resolved,
88
- error: `Refusing to operate on system directory: "${resolved}"`
89
- };
90
- }
91
- }
92
-
93
- return { valid: true, resolved };
94
- }
95
-
96
- /**
97
- * Check if a file is safe to move/delete (not a system critical file)
98
- * @param {string} filePath - Absolute file path
99
- * @returns {{ safe: boolean, reason?: string }}
100
- */
101
- function isFileSafe(filePath) {
102
- const ext = path.extname(filePath).toLowerCase();
103
- const basename = path.basename(filePath);
104
-
105
- // Reject system-critical extensions
106
- if (DANGEROUS_EXTENSIONS.has(ext)) {
107
- return { safe: false, reason: `System file extension "${ext}" is protected` };
108
- }
109
-
110
- // Reject Windows system files
111
- const systemFiles = ['ntldr', 'bootmgr', 'pagefile.sys', 'swapfile.sys', 'hiberfil.sys'];
112
- if (systemFiles.includes(basename.toLowerCase())) {
113
- return { safe: false, reason: `System file "${basename}" is protected` };
114
- }
115
-
116
- // Reject Unix critical files
117
- const unixCritical = ['.bashrc', '.zshrc', '.profile', '.bash_profile', '.ssh'];
118
- if (unixCritical.includes(basename.toLowerCase())) {
119
- return { safe: false, reason: `User config file "${basename}" is protected` };
120
- }
121
-
122
- return { safe: true };
123
- }
124
-
125
- /**
126
- * Check if a directory is safe to scan/organize
127
- * @param {string} dirPath - Absolute directory path
128
- * @returns {{ safe: boolean, reason?: string }}
129
- */
130
- function isDirSafe(dirPath) {
131
- const validation = validatePath(dirPath);
132
- if (!validation.valid) {
133
- return { safe: false, reason: validation.error };
134
- }
135
-
136
- // Don't operate on root of any drive
137
- const parsed = path.parse(validation.resolved);
138
- if (parsed.dir === parsed.root && parsed.base === '') {
139
- return { safe: false, reason: 'Cannot operate on filesystem root' };
140
- }
141
-
142
- return { safe: true };
143
- }
144
-
145
- // ─── Input Sanitization ───────────────────────────────────────────
146
-
147
- /**
148
- * Sanitize a filename for safe filesystem operations
149
- * @param {string} filename - Raw filename
150
- * @returns {string} Sanitized filename
151
- */
152
- function sanitizeFilename(filename) {
153
- if (!filename || typeof filename !== 'string') return 'unnamed';
154
-
155
- return filename
156
- // Remove null bytes
157
- .replace(/\0/g, '')
158
- // Remove path separators
159
- .replace(/[/\\]/g, '_')
160
- // Remove other dangerous characters
161
- .replace(/[<>:"|?*]/g, '_')
162
- // Remove control characters
163
- .replace(/[\x00-\x1f\x80-\x9f]/g, '')
164
- // Collapse multiple underscores/spaces
165
- .replace(/_{2,}/g, '_')
166
- .replace(/\s{2,}/g, ' ')
167
- // Remove leading/trailing dots and spaces (Windows issue)
168
- .replace(/^[\s.]+|[\s.]+$/g, '')
169
- // Ensure not empty after sanitization
170
- || 'unnamed';
171
- }
172
-
173
- /**
174
- * Sanitize a directory name
175
- * @param {string} dirname - Raw directory name
176
- * @returns {string} Sanitized directory name
177
- */
178
- function sanitizeDirname(dirname) {
179
- return sanitizeFilename(dirname);
180
- }
181
-
182
- // ─── Permission Checks ───────────────────────────────────────────
183
-
184
- /**
185
- * Check if we have read access to a path
186
- * @param {string} targetPath - Path to check
187
- * @returns {boolean} Whether we have read access
188
- */
189
- function canRead(targetPath) {
190
- try {
191
- fs.accessSync(targetPath, fs.constants.R_OK);
192
- return true;
193
- } catch {
194
- return false;
195
- }
196
- }
197
-
198
- /**
199
- * Check if we have write access to a path
200
- * @param {string} targetPath - Path to check
201
- * @returns {boolean} Whether we have write access
202
- */
203
- function canWrite(targetPath) {
204
- try {
205
- fs.accessSync(targetPath, fs.constants.W_OK);
206
- return true;
207
- } catch {
208
- return false;
209
- }
210
- }
211
-
212
- /**
213
- * Check full operation permissions for a directory
214
- * @param {string} dirPath - Directory to check
215
- * @returns {{ read: boolean, write: boolean, execute: boolean }}
216
- */
217
- function checkPermissions(dirPath) {
218
- const result = { read: false, write: false, execute: false };
219
- try { fs.accessSync(dirPath, fs.constants.R_OK); result.read = true; } catch { /* */ }
220
- try { fs.accessSync(dirPath, fs.constants.W_OK); result.write = true; } catch { /* */ }
221
- try { fs.accessSync(dirPath, fs.constants.X_OK); result.execute = true; } catch { /* */ }
222
- return result;
223
- }
224
-
225
- // ─── Rate Limiting (for CLI/server use) ───────────────────────────
226
-
227
- class OperationThrottle {
228
- constructor(maxOps = 1000, windowMs = 60000) {
229
- this.maxOps = maxOps;
230
- this.windowMs = windowMs;
231
- this.operations = [];
232
- }
233
-
234
- canProceed() {
235
- const now = Date.now();
236
- this.operations = this.operations.filter(t => now - t < this.windowMs);
237
- if (this.operations.length >= this.maxOps) {
238
- return {
239
- allowed: false,
240
- retryAfterMs: this.windowMs - (now - this.operations[0]),
241
- reason: `Rate limit: max ${this.maxOps} operations per ${this.windowMs / 1000}s`
242
- };
243
- }
244
- this.operations.push(now);
245
- return { allowed: true };
246
- }
247
-
248
- reset() {
249
- this.operations = [];
250
- }
251
- }
252
-
253
- // ─── Integrity Verification ───────────────────────────────────────
254
-
255
- /**
256
- * Create a pre-operation snapshot for rollback verification
257
- * @param {string} filePath - File to snapshot
258
- * @returns {{ exists: boolean, size?: number, modified?: Date, path: string }}
259
- */
260
- function createSnapshot(filePath) {
261
- try {
262
- const stat = fs.statSync(filePath);
263
- return {
264
- exists: true,
265
- size: stat.size,
266
- modified: stat.mtime,
267
- path: path.resolve(filePath)
268
- };
269
- } catch {
270
- return { exists: false, path: path.resolve(filePath) };
271
- }
272
- }
273
-
274
- /**
275
- * Verify a file matches a previous snapshot
276
- * @param {string} filePath - File to verify
277
- * @param {Object} snapshot - Previous snapshot
278
- * @returns {{ matches: boolean, differences: string[] }}
279
- */
280
- function verifySnapshot(filePath, snapshot) {
281
- const differences = [];
282
- try {
283
- const stat = fs.statSync(filePath);
284
- if (!snapshot.exists) {
285
- differences.push('File now exists but previously did not');
286
- } else {
287
- if (stat.size !== snapshot.size) {
288
- differences.push(`Size changed: ${snapshot.size} → ${stat.size}`);
289
- }
290
- if (stat.mtime.getTime() !== snapshot.modified.getTime()) {
291
- differences.push('Modification time changed');
292
- }
293
- }
294
- } catch {
295
- if (snapshot.exists) {
296
- differences.push('File no longer exists');
297
- }
298
- }
299
- return { matches: differences.length === 0, differences };
300
- }
301
-
302
- module.exports = {
303
- validatePath,
304
- isFileSafe,
305
- isDirSafe,
306
- sanitizeFilename,
307
- sanitizeDirname,
308
- canRead,
309
- canWrite,
310
- checkPermissions,
311
- OperationThrottle,
312
- createSnapshot,
313
- verifySnapshot,
314
- PROTECTED_DIRECTORIES,
315
- PROTECTED_PREFIXES,
316
- DANGEROUS_EXTENSIONS
317
- };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — SECURITY
6
+ * Path traversal guards, input sanitization, and safety checks
7
+ * ═══════════════════════════════════════════════════════════════════
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ // ─── Dangerous Paths ──────────────────────────────────────────────
17
+ const PROTECTED_DIRECTORIES = new Set([
18
+ // System roots
19
+ '/', 'C:\\', 'C:\\Windows', 'C:\\Windows\\System32',
20
+ '/System', '/Library', '/usr', '/bin', '/sbin', '/etc',
21
+ '/boot', '/dev', '/proc', '/sys', '/run', '/var',
22
+
23
+ // User critical
24
+ path.join(os.homedir()), // Don't organize the entire home dir
25
+ ]);
26
+
27
+ const PROTECTED_PREFIXES = [
28
+ '/System', '/Library/System',
29
+ 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
30
+ 'C:\\ProgramData', 'C:\\Users\\Public',
31
+ '/usr/lib', '/usr/bin', '/usr/sbin',
32
+ '/var/lib', '/var/run', '/boot', '/dev', '/proc', '/sys'
33
+ ];
34
+
35
+ const DANGEROUS_EXTENSIONS = new Set([
36
+ '.sys', '.drv', '.dll', '.so', '.dylib', '.kext',
37
+ '.plist', '.reg', '.msc', '.cpl'
38
+ ]);
39
+
40
+ // ─── Project Substrates (v3.5) ────────────────────────────────────
41
+ const SUBSTRATE_MARKERS = [
42
+ '.git', '.svn', 'node_modules', 'package.json',
43
+ 'venv', '.venv', '__pycache__', 'env',
44
+ 'pom.xml', 'build.gradle', 'CMakeLists.txt',
45
+ 'Makefile', '.hg', 'composer.json', 'go.mod'
46
+ ];
47
+
48
+ // ─── Path Validation ──────────────────────────────────────────────
49
+
50
+ /**
51
+ * Resolve and normalize a path, preventing traversal attacks
52
+ * @param {string} inputPath - Raw path from user input
53
+ * @param {string} [basePath] - Optional base to restrict resolution within
54
+ * @returns {{ valid: boolean, resolved: string, error?: string }}
55
+ */
56
+ function validatePath(inputPath, basePath = null) {
57
+ if (!inputPath || typeof inputPath !== 'string') {
58
+ return { valid: false, resolved: '', error: 'Path is empty or invalid' };
59
+ }
60
+
61
+ // [Hacker-Proof] Reject null bytes (classic path traversal vector)
62
+ if (inputPath.includes('\0')) {
63
+ return { valid: false, resolved: '', error: 'Path contains null bytes (rejected)' };
64
+ }
65
+
66
+ // [Hacker-Proof] Resolve to absolute and normalize to handle '..' etc.
67
+ const resolved = path.resolve(inputPath);
68
+ const rLower = resolved.toLowerCase();
69
+
70
+ // [Hacker-Proof] Jail Logic: If a basePath is given, ensure resolved path stays within it
71
+ if (basePath) {
72
+ const resolvedBase = path.resolve(basePath).toLowerCase();
73
+
74
+ // [Elite Fix] Robust jailing: Target must start with base + separator or be the base itself
75
+ const isSafe = rLower === resolvedBase || rLower.startsWith(resolvedBase + path.sep);
76
+
77
+ if (!isSafe) {
78
+ return {
79
+ valid: false,
80
+ resolved,
81
+ error: `Security Alert: Path Traversal Attempted. "${resolved}" is outside scope.`
82
+ };
83
+ }
84
+ }
85
+
86
+ // [Hacker-Proof] Check against protected directories
87
+ if (PROTECTED_DIRECTORIES.has(resolved)) {
88
+ return {
89
+ valid: false,
90
+ resolved,
91
+ error: `Refusing to operate on protected directory: "${resolved}"`
92
+ };
93
+ }
94
+
95
+ // [Hacker-Proof] Check against protected prefixes (System/Library/Program Files)
96
+ for (const prefix of PROTECTED_PREFIXES) {
97
+ const normalizedPrefix = path.resolve(prefix).toLowerCase();
98
+ const rLower = resolved.toLowerCase();
99
+
100
+ // Block if exact match OR if it starts with prefix + separator
101
+ if (rLower === normalizedPrefix || rLower.startsWith(normalizedPrefix + path.sep)) {
102
+ return {
103
+ valid: false,
104
+ resolved,
105
+ error: `Refusal: Path is within a system-protected root ("${resolved}")`
106
+ };
107
+ }
108
+ }
109
+
110
+ return { valid: true, resolved };
111
+ }
112
+
113
+ /**
114
+ * Check if a file is safe to move/delete (not a system critical file)
115
+ * @param {string} filePath - Absolute file path
116
+ * @returns {{ safe: boolean, reason?: string }}
117
+ */
118
+ function isFileSafe(filePath) {
119
+ if (!filePath || typeof filePath !== 'string') {
120
+ return { safe: false, reason: 'File path is empty or invalid' };
121
+ }
122
+ const ext = path.extname(filePath).toLowerCase();
123
+ const basename = path.basename(filePath);
124
+
125
+ // Reject system-critical extensions
126
+ if (DANGEROUS_EXTENSIONS.has(ext)) {
127
+ return { safe: false, reason: `System file extension "${ext}" is protected` };
128
+ }
129
+
130
+ // Reject Windows system files
131
+ const systemFiles = ['ntldr', 'bootmgr', 'pagefile.sys', 'swapfile.sys', 'hiberfil.sys'];
132
+ if (systemFiles.includes(basename.toLowerCase())) {
133
+ return { safe: false, reason: `System file "${basename}" is protected` };
134
+ }
135
+
136
+ // Reject Unix critical files
137
+ const unixCritical = ['.bashrc', '.zshrc', '.profile', '.bash_profile', '.ssh'];
138
+ if (unixCritical.includes(basename.toLowerCase())) {
139
+ return { safe: false, reason: `User config file "${basename}" is protected` };
140
+ }
141
+
142
+ return { safe: true };
143
+ }
144
+
145
+ /**
146
+ * Check if a directory is a "Project Substrate" (should not be scattered)
147
+ * @param {string} dirPath - Directory to check
148
+ * @returns {boolean}
149
+ */
150
+ function isSubstrateCritical(dirPath) {
151
+ try {
152
+ const items = fs.readdirSync(dirPath);
153
+ return SUBSTRATE_MARKERS.some(marker => items.includes(marker));
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Find the nearest substrate parent for a file
161
+ * @param {string} filePath
162
+ * @returns {string|null}
163
+ */
164
+ function findNearestSubstrate(filePath) {
165
+ let current = path.dirname(path.resolve(filePath));
166
+ const root = path.parse(current).root;
167
+
168
+ while (current !== root) {
169
+ if (isSubstrateCritical(current)) return current;
170
+ current = path.dirname(current);
171
+ }
172
+ return null;
173
+ }
174
+
175
+
176
+ /**
177
+ * Check if a directory is safe to scan/organize
178
+ * @param {string} dirPath - Absolute directory path
179
+ * @returns {{ safe: boolean, reason?: string }}
180
+ */
181
+ function isDirSafe(dirPath) {
182
+ const validation = validatePath(dirPath);
183
+ if (!validation.valid) {
184
+ return { safe: false, reason: validation.error };
185
+ }
186
+
187
+ // Don't operate on root of any drive
188
+ const parsed = path.parse(validation.resolved);
189
+ if (parsed.dir === parsed.root && parsed.base === '') {
190
+ return { safe: false, reason: 'Cannot operate on filesystem root' };
191
+ }
192
+
193
+ return { safe: true };
194
+ }
195
+
196
+ // ─── Input Sanitization ───────────────────────────────────────────
197
+
198
+ /**
199
+ * Sanitize a filename for safe filesystem operations
200
+ * @param {string} filename - Raw filename
201
+ * @returns {string} Sanitized filename
202
+ */
203
+ function sanitizeFilename(filename) {
204
+ if (!filename || typeof filename !== 'string') return 'unnamed';
205
+
206
+ return filename
207
+ // Remove null bytes
208
+ .replace(/\0/g, '')
209
+ // Remove path separators
210
+ .replace(/[/\\]/g, '_')
211
+ // Remove other dangerous characters
212
+ .replace(/[<>:"|?*]/g, '_')
213
+ // Remove control characters
214
+ .replace(/[\x00-\x1f\x80-\x9f]/g, '')
215
+ // Collapse multiple underscores/spaces
216
+ .replace(/_{2,}/g, '_')
217
+ .replace(/\s{2,}/g, ' ')
218
+ // Remove leading/trailing dots and spaces (Windows issue)
219
+ .replace(/^[\s.]+|[\s.]+$/g, '')
220
+ // Ensure not empty after sanitization
221
+ || 'unnamed';
222
+ }
223
+
224
+ /**
225
+ * Sanitize a directory name
226
+ * @param {string} dirname - Raw directory name
227
+ * @returns {string} Sanitized directory name
228
+ */
229
+ function sanitizeDirname(dirname) {
230
+ return sanitizeFilename(dirname);
231
+ }
232
+
233
+ // ─── Permission Checks ───────────────────────────────────────────
234
+
235
+ /**
236
+ * Check if we have read access to a path
237
+ * @param {string} targetPath - Path to check
238
+ * @returns {boolean} Whether we have read access
239
+ */
240
+ function canRead(targetPath) {
241
+ try {
242
+ fs.accessSync(targetPath, fs.constants.R_OK);
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Check if we have write access to a path
251
+ * @param {string} targetPath - Path to check
252
+ * @returns {boolean} Whether we have write access
253
+ */
254
+ function canWrite(targetPath) {
255
+ try {
256
+ fs.accessSync(targetPath, fs.constants.W_OK);
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Check full operation permissions for a directory
265
+ * @param {string} dirPath - Directory to check
266
+ * @returns {{ read: boolean, write: boolean, execute: boolean }}
267
+ */
268
+ function checkPermissions(dirPath) {
269
+ const result = { read: false, write: false, execute: false };
270
+ try { fs.accessSync(dirPath, fs.constants.R_OK); result.read = true; } catch { /* */ }
271
+ try { fs.accessSync(dirPath, fs.constants.W_OK); result.write = true; } catch { /* */ }
272
+ try { fs.accessSync(dirPath, fs.constants.X_OK); result.execute = true; } catch { /* */ }
273
+ return result;
274
+ }
275
+
276
+ // ─── Rate Limiting (for CLI/server use) ───────────────────────────
277
+
278
+ class OperationThrottle {
279
+ constructor(maxOps = 1000, windowMs = 60000) {
280
+ this.maxOps = maxOps;
281
+ this.windowMs = windowMs;
282
+ this.operations = [];
283
+ }
284
+
285
+ canProceed() {
286
+ const now = Date.now();
287
+ this.operations = this.operations.filter(t => now - t < this.windowMs);
288
+ if (this.operations.length >= this.maxOps) {
289
+ return {
290
+ allowed: false,
291
+ retryAfterMs: this.windowMs - (now - this.operations[0]),
292
+ reason: `Rate limit: max ${this.maxOps} operations per ${this.windowMs / 1000}s`
293
+ };
294
+ }
295
+ this.operations.push(now);
296
+ return { allowed: true };
297
+ }
298
+
299
+ reset() {
300
+ this.operations = [];
301
+ }
302
+ }
303
+
304
+ // ─── Integrity Verification ───────────────────────────────────────
305
+
306
+ /**
307
+ * Create a pre-operation snapshot for rollback verification
308
+ * @param {string} filePath - File to snapshot
309
+ * @returns {{ exists: boolean, size?: number, modified?: Date, path: string }}
310
+ */
311
+ function createSnapshot(filePath) {
312
+ try {
313
+ const stat = fs.statSync(filePath);
314
+ return {
315
+ exists: true,
316
+ size: stat.size,
317
+ modified: stat.mtime,
318
+ path: path.resolve(filePath)
319
+ };
320
+ } catch {
321
+ return { exists: false, path: path.resolve(filePath) };
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Verify a file matches a previous snapshot
327
+ * @param {string} filePath - File to verify
328
+ * @param {Object} snapshot - Previous snapshot
329
+ * @returns {{ matches: boolean, differences: string[] }}
330
+ */
331
+ function verifySnapshot(filePath, snapshot) {
332
+ const differences = [];
333
+ try {
334
+ const stat = fs.statSync(filePath);
335
+ if (!snapshot.exists) {
336
+ differences.push('File now exists but previously did not');
337
+ } else {
338
+ if (stat.size !== snapshot.size) {
339
+ differences.push(`Size changed: ${snapshot.size} → ${stat.size}`);
340
+ }
341
+ if (stat.mtime.getTime() !== snapshot.modified.getTime()) {
342
+ differences.push('Modification time changed');
343
+ }
344
+ }
345
+ } catch {
346
+ if (snapshot.exists) {
347
+ differences.push('File no longer exists');
348
+ }
349
+ }
350
+ return { matches: differences.length === 0, differences };
351
+ }
352
+
353
+ module.exports = {
354
+ validatePath,
355
+ isFileSafe,
356
+ isDirSafe,
357
+ isSubstrateCritical,
358
+ findNearestSubstrate,
359
+ sanitizeFilename,
360
+ sanitizeDirname,
361
+ canRead,
362
+ canWrite,
363
+ checkPermissions,
364
+ OperationThrottle,
365
+ createSnapshot,
366
+ verifySnapshot,
367
+ PROTECTED_DIRECTORIES,
368
+ PROTECTED_PREFIXES,
369
+ DANGEROUS_EXTENSIONS
370
+ };