filemayor 2.0.5 → 3.6.0

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/jailer.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — THE JAILER (IMMUTABLE SECURITY LAYER)
6
+ *
7
+ * This is the final firewall between FileMayor's AI "intuition" and
8
+ * the actual operating system. Even if Gemini suggests moving files
9
+ * into System32, this module kills the operation cold.
10
+ *
11
+ * CRITICAL: This file must NEVER be modified by AI assistants.
12
+ * It is the immutable source of truth for filesystem safety.
13
+ * ═══════════════════════════════════════════════════════════════════
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+
22
+ // ─── Forbidden Zones (OS-Agnostic) ───────────────────────────────
23
+ const FORBIDDEN_ZONES = [
24
+ // Windows
25
+ 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)',
26
+ 'C:\\ProgramData', 'C:\\Users\\Public', 'C:\\Users\\Default',
27
+ // Linux / macOS
28
+ '/etc', '/usr', '/bin', '/sbin', '/var', '/root',
29
+ '/boot', '/dev', '/proc', '/sys', '/run',
30
+ // macOS specific
31
+ '/System', '/Library', '/private'
32
+ ].map(p => path.resolve(p).toLowerCase());
33
+
34
+ /**
35
+ * THE JAILER: Final security check for ALL FileMayor operations.
36
+ * Use this before ANY fs.rename, fs.unlink, or fs.copy operation.
37
+ */
38
+ class FileMayorJailer {
39
+ /**
40
+ * @param {string} rootDirectory - The user's working directory (the "jail")
41
+ */
42
+ constructor(rootDirectory = process.cwd()) {
43
+ // Resolve the root to its absolute, REAL path (no symlinks)
44
+ try {
45
+ this.safeRoot = fs.realpathSync(path.resolve(rootDirectory)).toLowerCase();
46
+ } catch {
47
+ this.safeRoot = path.resolve(rootDirectory).toLowerCase();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Validate that a target path is safe for FileMayor to operate on.
53
+ * Resolves symlinks to their TRUE destination before checking.
54
+ *
55
+ * @param {string} targetPath - The path to validate
56
+ * @returns {{ safe: boolean, realPath: string, error?: string }}
57
+ */
58
+ validate(targetPath) {
59
+ try {
60
+ if (!targetPath || typeof targetPath !== 'string') {
61
+ return { safe: false, realPath: '', error: 'Empty or invalid path' };
62
+ }
63
+
64
+ // Reject null bytes (classic injection vector)
65
+ if (targetPath.includes('\0')) {
66
+ return { safe: false, realPath: '', error: 'Null byte injection detected' };
67
+ }
68
+
69
+ const absoluteTarget = path.resolve(targetPath);
70
+
71
+ // [ANTI-SYMLINK] Resolve the REAL path (follows symlinks to true source)
72
+ // This defeats CVE-2025-55130 style symlink race conditions
73
+ let realTarget;
74
+ try {
75
+ realTarget = fs.existsSync(absoluteTarget)
76
+ ? fs.realpathSync(absoluteTarget)
77
+ : absoluteTarget;
78
+ } catch {
79
+ realTarget = absoluteTarget;
80
+ }
81
+
82
+ const normalizedTarget = realTarget.toLowerCase();
83
+
84
+ // [JAIL CHECK] Is the target inside our allowed project folder?
85
+ if (!normalizedTarget.startsWith(this.safeRoot + path.sep) &&
86
+ normalizedTarget !== this.safeRoot) {
87
+ return {
88
+ safe: false,
89
+ realPath: realTarget,
90
+ error: `JAIL BREACH: "${realTarget}" is outside the safe root "${this.safeRoot}"`
91
+ };
92
+ }
93
+
94
+ // [SYSTEM CHECK] Is the target a protected OS zone?
95
+ for (const zone of FORBIDDEN_ZONES) {
96
+ if (normalizedTarget.startsWith(zone + path.sep) || normalizedTarget === zone) {
97
+ return {
98
+ safe: false,
99
+ realPath: realTarget,
100
+ error: `SYSTEM PROTECTION: "${realTarget}" is within forbidden zone "${zone}"`
101
+ };
102
+ }
103
+ }
104
+
105
+ // [EXTENSION CHECK] Reject system-critical file types
106
+ const ext = path.extname(realTarget).toLowerCase();
107
+ const dangerousExts = new Set(['.sys', '.drv', '.dll', '.so', '.dylib', '.kext', '.plist', '.reg']);
108
+ if (dangerousExts.has(ext)) {
109
+ return {
110
+ safe: false,
111
+ realPath: realTarget,
112
+ error: `DANGEROUS FILE: "${ext}" files are system-critical and cannot be moved`
113
+ };
114
+ }
115
+
116
+ return { safe: true, realPath: realTarget };
117
+
118
+ } catch (err) {
119
+ return { safe: false, realPath: '', error: `Jailer error: ${err.message}` };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Validate a move operation (both source AND destination must be safe)
125
+ * @param {string} source - Source file path
126
+ * @param {string} destination - Destination file path
127
+ * @returns {{ safe: boolean, error?: string }}
128
+ */
129
+ validateMove(source, destination) {
130
+ const srcCheck = this.validate(source);
131
+ if (!srcCheck.safe) return { safe: false, error: `Source: ${srcCheck.error}` };
132
+
133
+ const dstCheck = this.validate(destination);
134
+ if (!dstCheck.safe) return { safe: false, error: `Destination: ${dstCheck.error}` };
135
+
136
+ return { safe: true };
137
+ }
138
+ }
139
+
140
+ // ─── Root/Sudo Kill-Switch ────────────────────────────────────────
141
+ // If running as root on Unix, exit immediately. FileMayor should
142
+ // NEVER have elevated privileges to limit blast radius.
143
+ function enforceUserSpace() {
144
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
145
+ console.error('❌ FILEMAYOR SECURITY ERROR: Running as root/sudo is strictly forbidden.');
146
+ console.error(' FileMayor is designed to run in User Space only.');
147
+ process.exit(1);
148
+ }
149
+ }
150
+
151
+ module.exports = { FileMayorJailer, enforceUserSpace, FORBIDDEN_ZONES };
package/core/license.js CHANGED
@@ -19,7 +19,7 @@ const crypto = require('crypto');
19
19
 
20
20
  const LICENSE_FILE = path.join(os.homedir(), '.filemayor-license.json');
21
21
  const KEY_PREFIX = 'FM';
22
- const CHECKSUM_SECRET = 'filemayor-chevza-2026';
22
+ const CHECKSUM_SECRET = process.env.FM_CHECKSUM_SECRET || 'filemayor-chevza-2026';
23
23
 
24
24
  /**
25
25
  * License tiers with capabilities
@@ -66,10 +66,12 @@ const BUILTIN_KEYS = {
66
66
  * @returns {string} 5-char checksum
67
67
  */
68
68
  function generateChecksum(body) {
69
- const hash = crypto.createHmac('sha256', CHECKSUM_SECRET)
70
- .update(body)
71
- .digest('hex');
72
- return hash.substring(0, 5).toUpperCase();
69
+ // Ported from Admin Dashboard simpleHash for cross-platform compatibility
70
+ let hash = 0;
71
+ for (let i = 0; i < body.length; i++) {
72
+ hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
73
+ }
74
+ return Math.abs(hash).toString(36).toUpperCase().padEnd(5, '0').substring(0, 5);
73
75
  }
74
76
 
75
77
  /**
@@ -284,16 +286,13 @@ function checkProFeature(feature, featureLabel) {
284
286
  return {
285
287
  allowed: false,
286
288
  message: [
287
- `⚡ ${featureLabel} is a Pro feature`,
289
+ c('yellow', `⚡ ${featureLabel} is a Pro Feature`),
288
290
  '',
289
- ' Upgrade to Pro to unlock:',
290
- ' Real-time file watching & auto-organize',
291
- ' • AI-powered SOP parsing (Gemini)',
292
- ' • Bulk organize (unlimited files)',
293
- ' • CSV export for all reports',
291
+ ` FileMayor Core is free. ${featureLabel} requires a Pro License.`,
292
+ ' Unlocks: Real-time Watch, AI SOP Engine, and Unlimited Bulk processing.',
294
293
  '',
295
- ' Get your license: https://filemayor.lemonsqueezy.com/checkout/buy/7fdcc87f-0660-4c1c-b3db-99f94773b71a',
296
- ' Then run: filemayor license activate YOUR-KEY',
294
+ ' Get Key: https://filemayor.com/pro',
295
+ ' Activate: filemayor license activate YOUR-KEY',
297
296
  '',
298
297
  ].join('\n'),
299
298
  };
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — METADATA STORE
6
+ * Lightweight, persistent indexing for S2 compliance (<300ms search).
7
+ * ═══════════════════════════════════════════════════════════════════
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs').promises;
13
+ const fssync = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const crypto = require('crypto');
17
+
18
+ class MetadataStore {
19
+ constructor(dbPath) {
20
+ this.rootPath = dbPath || path.join(os.homedir(), '.filemayor', 'metadata_index.json');
21
+ this.index = {
22
+ version: '1.0',
23
+ lastUpdated: null,
24
+ files: {} // path -> metadata
25
+ };
26
+ this._ensureDir();
27
+ this.load();
28
+ }
29
+
30
+ _ensureDir() {
31
+ const dir = path.dirname(this.rootPath);
32
+ if (!fssync.existsSync(dir)) {
33
+ fssync.mkdirSync(dir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ load() {
38
+ try {
39
+ if (fssync.existsSync(this.rootPath)) {
40
+ const data = fssync.readFileSync(this.rootPath, 'utf8');
41
+ this.index = JSON.parse(data);
42
+ }
43
+ } catch (err) {
44
+ console.error('[IDX] Failed to load index:', err.message);
45
+ }
46
+ }
47
+
48
+ async save() {
49
+ try {
50
+ this.index.lastUpdated = new Date().toISOString();
51
+ await fs.writeFile(this.rootPath, JSON.stringify(this.index, null, 2));
52
+ } catch (err) {
53
+ console.error('[IDX] Failed to save index:', err.message);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Update the index with fresh file metadata
59
+ * @param {Object[]} files
60
+ */
61
+ update(files) {
62
+ for (const file of files) {
63
+ this.index.files[file.path] = {
64
+ name: file.name,
65
+ size: file.size,
66
+ mtime: file.modified,
67
+ category: file.category,
68
+ ext: file.ext
69
+ };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Search the index with < 300ms latency
75
+ * @param {string} query
76
+ * @returns {Object[]}
77
+ */
78
+ search(query) {
79
+ const startTime = Date.now();
80
+ const results = [];
81
+ const lowerQuery = query.toLowerCase();
82
+
83
+ for (const [filePath, meta] of Object.entries(this.index.files)) {
84
+ if (meta.name.toLowerCase().includes(lowerQuery) || filePath.toLowerCase().includes(lowerQuery)) {
85
+ results.push({ path: filePath, ...meta });
86
+ }
87
+ if (results.length > 100) break; // Cap results for performance
88
+ }
89
+
90
+ const duration = Date.now() - startTime;
91
+ return {
92
+ results,
93
+ duration,
94
+ totalFound: results.length
95
+ };
96
+ }
97
+
98
+ clear() {
99
+ this.index.files = {};
100
+ this.save();
101
+ }
102
+ }
103
+
104
+ module.exports = new MetadataStore();
package/core/organizer.js CHANGED
@@ -14,7 +14,10 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { categorize, getCategories } = require('./categories');
16
16
  const { scan } = require('./scanner');
17
- const { validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot } = require('./security');
17
+ const {
18
+ validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot,
19
+ findNearestSubstrate
20
+ } = require('./security');
18
21
  const { formatBytes } = require('./scanner');
19
22
 
20
23
  // ─── Naming Conventions ───────────────────────────────────────────
@@ -175,6 +178,13 @@ function generatePlan(srcDir, options = {}) {
175
178
 
176
179
  // Build the plan
177
180
  for (const file of scanResult.files) {
181
+ // Substrate Protection (v3.5)
182
+ // If file is inside a project substrate, DO NOT scatter it by default
183
+ const projectRoot = findNearestSubstrate(file.path);
184
+ if (projectRoot) {
185
+ continue; // Skip project-locked files
186
+ }
187
+
178
188
  const category = file.category;
179
189
  categoryCounts[category] = (categoryCounts[category] || 0);
180
190
 
@@ -241,22 +251,23 @@ function generatePlan(srcDir, options = {}) {
241
251
 
242
252
  // ─── Execution ────────────────────────────────────────────────────
243
253
 
254
+ const FileMayorFS = require('./fs-abstraction');
255
+ const fmfs = new FileMayorFS();
256
+
244
257
  /**
245
258
  * Execute a move plan with journal logging for rollback
246
259
  * @param {MovePlanItem[]} plan - The move plan to execute
247
260
  * @param {Object} options - Execution options
248
261
  * @returns {{ results: Object[], journal: Object[], summary: Object }}
249
262
  */
250
- function executePlan(plan, options = {}) {
263
+ async function executePlan(plan, options = {}) {
251
264
  const {
252
265
  onProgress = null,
253
266
  onError = null,
254
- journalPath = null, // Path to save rollback journal
255
- abortOnError = false, // Stop on first error
267
+ abortOnError = false,
256
268
  } = options;
257
269
 
258
270
  const results = [];
259
- const journal = [];
260
271
  let succeeded = 0;
261
272
  let failed = 0;
262
273
  let skipped = 0;
@@ -264,7 +275,6 @@ function executePlan(plan, options = {}) {
264
275
  for (let i = 0; i < plan.length; i++) {
265
276
  const item = plan[i];
266
277
 
267
- // Progress callback
268
278
  if (onProgress) {
269
279
  onProgress({
270
280
  current: i + 1,
@@ -276,104 +286,21 @@ function executePlan(plan, options = {}) {
276
286
  });
277
287
  }
278
288
 
279
- // Skip items marked as skip
280
289
  if (item.action === 'skip') {
281
290
  results.push({ ...item, status: 'skipped', error: null });
282
291
  skipped++;
283
292
  continue;
284
293
  }
285
294
 
286
- // Safety check
287
- const safeCheck = isFileSafe(item.source);
288
- if (!safeCheck.safe) {
289
- const error = `Safety check failed: ${safeCheck.reason}`;
290
- results.push({ ...item, status: 'error', error });
291
- failed++;
292
- if (onError) onError({ item, error });
293
- if (abortOnError) break;
294
- continue;
295
- }
296
-
297
- // Create destination directory
298
- const destDir = path.dirname(item.destination);
299
295
  try {
300
- fs.mkdirSync(destDir, { recursive: true });
301
- } catch (err) {
302
- const error = `Failed to create directory: ${err.message}`;
303
- results.push({ ...item, status: 'error', error });
304
- failed++;
305
- if (onError) onError({ item, error });
306
- if (abortOnError) break;
307
- continue;
308
- }
309
-
310
- // Create pre-move snapshot for integrity
311
- const snapshot = createSnapshot(item.source);
312
-
313
- // Execute the move
314
- try {
315
- // Handle overwrite
316
- if (item.action === 'overwrite' && fs.existsSync(item.destination)) {
317
- fs.unlinkSync(item.destination);
318
- }
319
-
320
- // Perform the move
321
- fs.renameSync(item.source, item.destination);
322
-
323
- // Journal entry for rollback
324
- const journalEntry = {
325
- source: item.source,
326
- destination: item.destination,
327
- originalName: item.originalName,
328
- newName: item.newName,
329
- size: item.size,
330
- timestamp: Date.now(),
331
- snapshot
332
- };
333
- journal.push(journalEntry);
334
-
296
+ await fmfs.move(item.source, item.destination);
335
297
  results.push({ ...item, status: 'success', error: null });
336
298
  succeeded++;
337
299
  } catch (err) {
338
- // If rename fails (cross-device), try copy + delete
339
- try {
340
- fs.copyFileSync(item.source, item.destination);
341
- fs.unlinkSync(item.source);
342
-
343
- const journalEntry = {
344
- source: item.source,
345
- destination: item.destination,
346
- originalName: item.originalName,
347
- newName: item.newName,
348
- size: item.size,
349
- timestamp: Date.now(),
350
- snapshot,
351
- crossDevice: true
352
- };
353
- journal.push(journalEntry);
354
-
355
- results.push({ ...item, status: 'success', error: null });
356
- succeeded++;
357
- } catch (copyErr) {
358
- const error = `Move failed: ${copyErr.message}`;
359
- results.push({ ...item, status: 'error', error });
360
- failed++;
361
- if (onError) onError({ item, error });
362
- if (abortOnError) break;
363
- }
364
- }
365
- }
366
-
367
- // Save journal to disk if path provided
368
- if (journalPath && journal.length > 0) {
369
- try {
370
- const existing = fs.existsSync(journalPath)
371
- ? JSON.parse(fs.readFileSync(journalPath, 'utf8'))
372
- : [];
373
- const merged = [...existing, ...journal];
374
- fs.writeFileSync(journalPath, JSON.stringify(merged, null, 2), 'utf8');
375
- } catch (err) {
376
- // Journal save failed — non-fatal
300
+ results.push({ ...item, status: 'error', error: err.message });
301
+ failed++;
302
+ if (onError) onError({ item, error: err.message });
303
+ if (abortOnError) break;
377
304
  }
378
305
  }
379
306
 
@@ -386,7 +313,7 @@ function executePlan(plan, options = {}) {
386
313
  totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
387
314
  };
388
315
 
389
- return { results, journal, summary };
316
+ return { results, journal: fmfs.getJournal(), summary };
390
317
  }
391
318
 
392
319
  // ─── Rollback (Undo) ──────────────────────────────────────────────
@@ -395,50 +322,11 @@ function executePlan(plan, options = {}) {
395
322
  * Rollback operations using a journal
396
323
  * @param {Object[]} journal - Journal entries from executePlan
397
324
  * @param {Object} options - Rollback options
398
- * @returns {{ undone: number, failed: number, errors: string[] }}
399
325
  */
400
- function rollback(journal, options = {}) {
401
- const { onProgress = null } = options;
402
- let undone = 0;
403
- let failed = 0;
404
- const errors = [];
405
-
406
- // Process in reverse order (most recent first)
407
- const reversed = [...journal].reverse();
408
-
409
- for (let i = 0; i < reversed.length; i++) {
410
- const entry = reversed[i];
411
-
412
- if (onProgress) {
413
- onProgress({
414
- current: i + 1,
415
- total: reversed.length,
416
- percent: Math.round(((i + 1) / reversed.length) * 100),
417
- file: entry.originalName
418
- });
419
- }
420
-
421
- try {
422
- // Ensure source directory exists
423
- const sourceDir = path.dirname(entry.source);
424
- fs.mkdirSync(sourceDir, { recursive: true });
425
-
426
- // Move back
427
- if (entry.crossDevice) {
428
- fs.copyFileSync(entry.destination, entry.source);
429
- fs.unlinkSync(entry.destination);
430
- } else {
431
- fs.renameSync(entry.destination, entry.source);
432
- }
433
-
434
- undone++;
435
- } catch (err) {
436
- failed++;
437
- errors.push(`Failed to undo ${entry.originalName}: ${err.message}`);
438
- }
439
- }
440
-
441
- return { undone, failed, errors };
326
+ async function rollback(journal, options = {}) {
327
+ // Fill the fmfs internal journal with the passed journal to use its logic
328
+ fmfs.sessionJournal = journal;
329
+ return await fmfs.rollback();
442
330
  }
443
331
 
444
332
  /**
@@ -463,13 +351,12 @@ function loadJournal(journalPath) {
463
351
  * @param {Object} options - Full options
464
352
  * @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
465
353
  */
466
- function organize(dirPath, options = {}) {
354
+ async function organize(dirPath, options = {}) {
467
355
  const {
468
356
  dryRun = false,
469
357
  naming = 'original',
470
358
  outputDir = null,
471
359
  duplicateStrategy = 'rename',
472
- journalPath = null,
473
360
  onProgress = null,
474
361
  onError = null,
475
362
  abortOnError = false,
@@ -498,10 +385,9 @@ function organize(dirPath, options = {}) {
498
385
  }
499
386
 
500
387
  // Execute
501
- const { results, journal, summary: execSummary } = executePlan(plan, {
388
+ const { results, journal, summary: execSummary } = await executePlan(plan, {
502
389
  onProgress,
503
390
  onError,
504
- journalPath: journalPath || path.join(dirPath, '.filemayor-journal.json'),
505
391
  abortOnError
506
392
  });
507
393