@miraj181/ipingyou 2.1.9 → 2.1.18

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 CHANGED
@@ -20,7 +20,7 @@ No firewalls to configure. No port forwarding. No plaintext leakage.
20
20
  * 📺 **Terminal Mirroring**: Wrap client SSH sessions in a multiplexed `tmux` terminal. The Host can spectate connected clients in real-time right from the dashboard to audit or assist.
21
21
  * 🔄 **Reverse Port Forwarding (`ssh -R`)**: Clients can expose their *local* `localhost` development ports back to the Host through the secure tunnel.
22
22
  * 📡 **Hardware Telemetry Verification**: Clients silently generate hardware footprint reports (OS, RAM, CPU, IP), encrypt them locally with the session password, and send them to the Host for authorization.
23
- * 🚨 **Panic Kill-Switch**: Type `ipingyou panic` to instantly vaporize all associated keys, wipe all alias configs, and send a `SIGKILL` to every active tunnel and SSH shell.
23
+ * 🚨 **Scoped Emergency Stop**: Type `ipingyou panic` and confirm locally to stop only processes and temporary credentials owned by the current iPingYou session.
24
24
  * 👻 **Daemonization**: Run `ipingyou service install` to quietly install and run the Host listener in the background (survives system reboots using PM2).
25
25
  * 🧭 **Approval Gate**: Require the Host to explicitly approve clients before they receive tunnel/key material.
26
26
  * 📦 **One-Time File Share**: Serve a single file/folder over SCP and revoke after use.
@@ -48,12 +48,17 @@ npx @miraj181/ipingyou connect
48
48
 
49
49
  ### Global Install
50
50
  ```bash
51
- npm install -g @miraj181/ipingyou
51
+ # Install Socket Firewall once, then scan iPingYou before installation
52
+ npm install -g sfw
53
+ sfw npm install -g @miraj181/ipingyou
52
54
 
53
55
  # Execute globally using aliases:
54
56
  ipingyou
55
57
  # or
56
58
  securelink
59
+
60
+ # Verify future in-app npm installs are firewall-protected
61
+ ipingyou security-status
57
62
  ```
58
63
 
59
64
  ---
@@ -102,7 +107,7 @@ sequenceDiagram
102
107
 
103
108
  ## 🛡️ Security Scanner Disclaimer
104
109
 
105
- Because **iPingYou** is a powerful remote administration tool with features like background daemonization (via PM2), secure shell execution (`execa`), and anti-forensics capabilities (`panic` mode), automated security scanners (such as Socket.dev or enterprise EDRs) may flag this package as a **potential risk** or **malware-like**.
110
+ Because **iPingYou** is a remote-access tool with background daemonization and SSH process management, automated security scanners may classify it as security-sensitive. Native dependencies are never downloaded or installed automatically, and emergency cleanup is limited to resources registered by the current process.
106
111
 
107
112
  These alerts (e.g., "AI-detected potential code anomaly", "Shell access", "Network access") are **expected behavior** for a peer-to-peer tunneling utility. The source code is entirely open-source, heavily documented, and uses zero-knowledge encryption to ensure your data is safe.
108
113
 
@@ -128,7 +133,7 @@ These alerts (e.g., "AI-detected potential code anomaly", "Shell access", "Netwo
128
133
  | `ipingyou connect -u <UID>` | Connect directly to a specific UID. |
129
134
  | `ipingyou ai` | Groq-powered task assistant with guarded local/remote tools. |
130
135
  | `ipingyou doctor` | Diagnostics for dependencies, SSH, broker, SCP, AI, and tests. |
131
- | `ipingyou panic` | 🚨 Self-destruct mode. Wipes configs, memory, and kills all processes. |
136
+ | `ipingyou panic` | 🚨 Confirmed emergency stop for the current iPingYou session only. |
132
137
  | `ipingyou service install` | 👻 Installs Host mode as an always-on background daemon. |
133
138
  | `ipingyou service stop` | Stops and removes the background daemon. |
134
139
  | `ipingyou service status` | Shows background daemon status. |
package/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Use this section to tell people about which versions of your project are
6
+ currently being supported with security updates.
7
+
8
+ | Version | Supported |
9
+ | ------- | ------------------ |
10
+ | 5.1.x | :white_check_mark: |
11
+ | 5.0.x | :x: |
12
+ | 4.0.x | :white_check_mark: |
13
+ | < 4.0 | :x: |
14
+
15
+ ## Reporting a Vulnerability
16
+
17
+ Use this section to tell people how to report a vulnerability.
18
+
19
+ Tell them where to go, how often they can expect to get an update on a
20
+ reported vulnerability, what to expect if the vulnerability is accepted or
21
+ declined, etc.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraj181/ipingyou",
3
- "version": "2.1.9",
3
+ "version": "2.1.18",
4
4
  "description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -13,12 +13,13 @@
13
13
  "src/lib/",
14
14
  "src/modes/",
15
15
  "README.md",
16
+ "SECURITY.md",
16
17
  "LICENSE"
17
18
  ],
18
19
  "scripts": {
19
20
  "start": "node src/server.js",
20
21
  "dev:cli": "node src/cli.js",
21
- "test": "node test/test_ai_helpers.js",
22
+ "test": "node test/test_ai_helpers.js && node test/test_security_static.js && node test/test_e2e_crypto.js",
22
23
  "prepublishOnly": "node -e \"require('fs').accessSync('LICENSE')\" && node src/cli.js --help"
23
24
  },
24
25
  "keywords": [
@@ -44,22 +45,21 @@
44
45
  "url": "https://github.com/skmirajulislam/ipingyou/issues"
45
46
  },
46
47
  "dependencies": {
47
- "chalk": "^5.3.0",
48
- "commander": "^15.0.0",
49
- "execa": "^9.5.2",
50
- "inquirer": "^12.11.1",
51
- "nanoid": "^5.0.9",
52
- "open": "^11.0.0",
53
- "ora": "^9.4.0",
54
- "shell-quote": "^1.8.4",
55
- "tree-kill": "^1.2.2",
56
- "ws": "^8.20.1"
48
+ "chalk": "5.6.2",
49
+ "commander": "15.0.0",
50
+ "express": "4.22.2",
51
+ "express-rate-limit": "7.5.1",
52
+ "execa": "9.6.1",
53
+ "helmet": "8.2.0",
54
+ "inquirer": "12.11.1",
55
+ "ora": "9.4.1",
56
+ "ws": "8.21.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "nodemon": "^3.1.4"
60
60
  },
61
61
  "engines": {
62
- "node": ">=18.0.0"
62
+ "node": ">=22.12.0"
63
63
  },
64
64
  "type": "module"
65
65
  }
package/src/cli.js CHANGED
@@ -23,11 +23,13 @@ import inquirer from 'inquirer';
23
23
  import chalk from 'chalk';
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
+ import readline from 'node:readline';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
28
29
  import { detectOS, checkDependencies } from './lib/platform.js';
29
30
  import { cleanupAll, installShutdownHandlers, executePanicMode } from './lib/cleanup.js';
30
31
  import { cleanupSessionLog } from './lib/session-log.js';
32
+ import { getSocketFirewallStatus, runProtectedNpmInstall } from './lib/socket-firewall.js';
31
33
  import { startHostMode } from './modes/host.js';
32
34
  import { startClientMode } from './modes/client.js';
33
35
  import { startAIMode } from './modes/ai.js';
@@ -329,10 +331,19 @@ program
329
331
 
330
332
  program
331
333
  .command('panic')
332
- .description('🚨 Self-destruct mode: wipe all configs, kill tunnels, and remove traces')
334
+ .description('🚨 Stop resources owned by the current iPingYou session')
333
335
  .action(async () => {
334
336
  try {
335
337
  showBanner();
338
+ const { confirmation } = await inquirer.prompt([{
339
+ type: 'input',
340
+ name: 'confirmation',
341
+ message: 'Type STOP CURRENT SESSION to confirm:',
342
+ }]);
343
+ if (confirmation !== 'STOP CURRENT SESSION') {
344
+ console.log(chalk.yellow(' Emergency shutdown cancelled.'));
345
+ return;
346
+ }
336
347
  await executePanicMode();
337
348
  } catch (err) {
338
349
  fatal('panic', err);
@@ -351,8 +362,8 @@ program
351
362
  const { execa } = await import('execa');
352
363
 
353
364
  if (action === 'install') {
354
- console.log(chalk.dim(' Installing PM2 globally and starting host...'));
355
- await execa('npm', ['install', '-g', 'pm2'], { stdio: 'inherit' });
365
+ console.log(chalk.dim(' Scanning and installing PM2 through Socket Firewall...'));
366
+ await runProtectedNpmInstall(['-g', 'pm2'], { stdio: 'inherit' });
356
367
  await execa('pm2', ['start', 'ipingyou', '--name', 'ipingyou-host', '--', 'host'], { stdio: 'inherit' });
357
368
  await execa('pm2', ['save'], { stdio: 'inherit' });
358
369
  await execa('pm2', ['startup'], { stdio: 'inherit' });
@@ -372,6 +383,26 @@ program
372
383
  }
373
384
  });
374
385
 
386
+ program
387
+ .command('security-status')
388
+ .description('Check Socket Firewall protection for future npm installations')
389
+ .action(async () => {
390
+ try {
391
+ showBanner();
392
+ const status = await getSocketFirewallStatus();
393
+ if (!status.available) {
394
+ console.log(chalk.yellow(' ⚠️ Socket Firewall is not available.'));
395
+ console.log(chalk.dim(' Install it with: npm install -g sfw'));
396
+ process.exitCode = 1;
397
+ return;
398
+ }
399
+ console.log(chalk.green(` ✅ Socket Firewall active${status.version ? ` (${status.version})` : ''}`));
400
+ console.log(chalk.dim(' In-app npm installations are routed through: sfw npm install'));
401
+ } catch (err) {
402
+ fatal('security-status', err);
403
+ }
404
+ });
405
+
375
406
  program
376
407
  .command('allowlist')
377
408
  .description('Manage AI command allowlist — add, remove, or list safe regex patterns')
@@ -474,26 +505,28 @@ program
474
505
  return;
475
506
  }
476
507
 
477
- const raw = fs.readFileSync(logFile, 'utf8').trim();
478
- if (!raw) {
479
- console.log(chalk.dim(' Log file is empty.'));
480
- return;
481
- }
482
-
483
- let events = raw.split('\n').map(line => {
484
- try { return JSON.parse(line); } catch { return null; }
485
- }).filter(Boolean);
486
-
487
- // Filter by type if specified
488
- if (commandOptions.type) {
489
- const filter = commandOptions.type.toLowerCase();
490
- events = events.filter(e => (e.type || '').toLowerCase().includes(filter));
508
+ const parsedCount = Number.parseInt(commandOptions.lines, 10);
509
+ const count = Number.isFinite(parsedCount) ? Math.max(1, Math.min(parsedCount, 10000)) : 25;
510
+ const filter = commandOptions.type?.toLowerCase();
511
+ const events = [];
512
+ let totalEvents = 0;
513
+ const lines = readline.createInterface({
514
+ input: fs.createReadStream(logFile, { encoding: 'utf8' }),
515
+ crlfDelay: Infinity,
516
+ });
517
+ for await (const line of lines) {
518
+ if (!line.trim()) continue;
519
+ totalEvents += 1;
520
+ try {
521
+ const event = JSON.parse(line);
522
+ if (filter && !(event.type || '').toLowerCase().includes(filter)) continue;
523
+ events.push(event);
524
+ if (events.length > count) events.shift();
525
+ } catch {
526
+ // Ignore incomplete or invalid log lines.
527
+ }
491
528
  }
492
529
 
493
- // Take last N events
494
- const count = parseInt(commandOptions.lines) || 25;
495
- events = events.slice(-count);
496
-
497
530
  if (events.length === 0) {
498
531
  console.log(chalk.dim(' No matching events found.'));
499
532
  return;
@@ -526,7 +559,7 @@ program
526
559
 
527
560
  console.log('');
528
561
  console.log(chalk.dim(` Log file: ${logFile}`));
529
- console.log(chalk.dim(` Total events in file: ${raw.split('\n').length}`));
562
+ console.log(chalk.dim(` Total events in file: ${totalEvents}`));
530
563
  } catch (err) {
531
564
  fatal('history', err);
532
565
  }
@@ -1,3 +1,5 @@
1
+ import { getAllowlistRegexes } from '../allowlist.js';
2
+
1
3
  const SECRET_PATTERNS = [
2
4
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
3
5
  /\b(gsk_[A-Za-z0-9_-]{20,})\b/g,
@@ -20,6 +22,31 @@ const BLOCKED_PATH_PATTERNS = [
20
22
  /id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
21
23
  ];
22
24
 
25
+ const BLOCKED_PATH_SEGMENTS = new Set([
26
+ '.ssh',
27
+ '.gnupg',
28
+ '.aws',
29
+ '.ipingyou',
30
+ '.kube',
31
+ '.docker',
32
+ ]);
33
+
34
+ const BLOCKED_PATH_BASENAMES = new Set([
35
+ '.npmrc',
36
+ '.netrc',
37
+ '.pypirc',
38
+ 'credentials',
39
+ 'credentials.json',
40
+ 'known_hosts',
41
+ 'authorized_keys',
42
+ 'shadow',
43
+ 'sudoers',
44
+ ]);
45
+
46
+ const PRIVATE_KEY_BASENAME = /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i;
47
+ const ENV_BASENAME = /^\.env(?:\.|$)/i;
48
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
49
+
23
50
  const BLOCKED_COMMAND_PATTERNS = [
24
51
  /^\s*(env|printenv|set)\b/,
25
52
  /\b(export|declare)\s+-p\b/,
@@ -41,8 +68,7 @@ const DANGEROUS_COMMAND_PATTERNS = [
41
68
  /\b(curl|wget)\b/,
42
69
  />|>>|\btee\b/,
43
70
  ];
44
-
45
- import { getAllowlistRegexes } from '../allowlist.js';
71
+ const SHELL_SYNTAX_PATTERN = /(?:[;&|`$()]|\\\r?\n)/;
46
72
 
47
73
  const READ_ONLY_COMMAND_PATTERNS = [
48
74
  /^\s*(pwd|ls|find|rg|grep|sed|cat|head|tail|wc|git status|git diff|git log|git show|node --version|npm --version|which|date|uname)\b/,
@@ -66,6 +92,33 @@ export function commandTouchesBlockedPath(command) {
66
92
  return BLOCKED_PATH_PATTERNS.some(pattern => pattern.test(command));
67
93
  }
68
94
 
95
+ export function assertSafeReadablePath(filePath) {
96
+ const value = String(filePath ?? '');
97
+ if (!value || value.length > 4096) {
98
+ throw new Error('Invalid file path');
99
+ }
100
+ if (CONTROL_CHARACTERS.test(value)) {
101
+ throw new Error('File path contains control characters');
102
+ }
103
+
104
+ const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/');
105
+ const segments = normalized.toLowerCase().split('/').filter(Boolean);
106
+ const basename = segments.at(-1) || '';
107
+
108
+ if (
109
+ segments.some(segment => BLOCKED_PATH_SEGMENTS.has(segment))
110
+ || BLOCKED_PATH_BASENAMES.has(basename)
111
+ || PRIVATE_KEY_BASENAME.test(basename)
112
+ || ENV_BASENAME.test(basename)
113
+ || normalized.toLowerCase().includes('/.config/gh/')
114
+ || normalized.toLowerCase().includes('/proc/') && basename === 'environ'
115
+ ) {
116
+ throw new Error('Path references a protected secret/config location');
117
+ }
118
+
119
+ return value;
120
+ }
121
+
69
122
  export function classifyCommand(command) {
70
123
  const text = String(command || '').trim();
71
124
  if (!text) return { blocked: true, needsApproval: true, reason: 'Empty command' };
@@ -78,16 +131,6 @@ export function classifyCommand(command) {
78
131
  };
79
132
  }
80
133
 
81
- // Check user-provided allowlist first (if present)
82
- try {
83
- const userPatterns = getAllowlistRegexes();
84
- if (Array.isArray(userPatterns) && userPatterns.some(p => p.test(text))) {
85
- return { blocked: false, needsApproval: false, reason: 'Matched user allowlist' };
86
- }
87
- } catch {
88
- // ignore allowlist errors and fall back to defaults
89
- }
90
-
91
134
  if (BLOCKED_COMMAND_PATTERNS.some(pattern => pattern.test(text))) {
92
135
  return {
93
136
  blocked: true,
@@ -100,6 +143,20 @@ export function classifyCommand(command) {
100
143
  return { blocked: false, needsApproval: true, reason: 'Command may modify files, install packages, or access the network' };
101
144
  }
102
145
 
146
+ if (SHELL_SYNTAX_PATTERN.test(text)) {
147
+ return { blocked: false, needsApproval: true, reason: 'Command uses shell syntax and requires explicit approval' };
148
+ }
149
+
150
+ // User allowlists may suppress prompts only after non-bypassable safety checks.
151
+ try {
152
+ const userPatterns = getAllowlistRegexes();
153
+ if (Array.isArray(userPatterns) && userPatterns.some(pattern => pattern.test(text))) {
154
+ return { blocked: false, needsApproval: false, reason: 'Matched user allowlist' };
155
+ }
156
+ } catch {
157
+ // Ignore allowlist errors and fall back to defaults.
158
+ }
159
+
103
160
  if (READ_ONLY_COMMAND_PATTERNS.some(pattern => pattern.test(text))) {
104
161
  return { blocked: false, needsApproval: false, reason: 'Read-only command' };
105
162
  }
package/src/lib/broker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import os from 'node:os';
3
3
  import crypto from 'node:crypto';
4
- import { decrypt, encrypt } from './crypto.js';
4
+ import { decryptAsync, encryptAsync } from './crypto.js';
5
5
  import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
6
6
  import { logSessionEvent } from './session-log.js';
7
7
 
@@ -51,7 +51,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
51
51
  try {
52
52
  await new Promise(r => setTimeout(r, 600));
53
53
  const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
54
- const encrypted = encrypt(payload, password);
54
+ const encrypted = await encryptAsync(payload, password);
55
55
  const localHostToken = crypto.randomBytes(32).toString('hex');
56
56
 
57
57
  spinner.text = 'Registering with broker...';
@@ -92,7 +92,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
92
92
  }
93
93
 
94
94
  export async function requestHostApproval(brokerUrl, uid, password, details) {
95
- const encrypted = encrypt(JSON.stringify(details), password);
95
+ const encrypted = await encryptAsync(JSON.stringify(details), password);
96
96
  const res = await fetchWithLog('approval_request', `${brokerUrl}/approval-request/${uid}`, {
97
97
  method: 'POST',
98
98
  headers: { 'Content-Type': 'application/json' },
@@ -209,7 +209,7 @@ export async function resolveUID(brokerUrl, uid, password, silent = false, reque
209
209
 
210
210
  let decryptedPayload;
211
211
  try {
212
- decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
212
+ decryptedPayload = await decryptAsync(data.iv, data.ciphertext, password, data.salt);
213
213
  } catch {
214
214
  if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
215
215
  if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
@@ -269,7 +269,7 @@ export async function pushTelemetry(brokerUrl, uid, password, username, action =
269
269
  time: new Date().toISOString()
270
270
  };
271
271
 
272
- const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
272
+ const { iv, ciphertext, salt } = await encryptAsync(JSON.stringify(telemetry), password);
273
273
 
274
274
  await fetchWithLog('telemetry', `${parsed.origin}/client-info/${encodeURIComponent(uid)}`, {
275
275
  method: 'POST',
package/src/lib/chat.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { WebSocketServer } from 'ws';
3
- import open from 'open';
3
+ import { openUrl } from './open-url.js';
4
4
  import chalk from 'chalk';
5
5
  import { secureSensitiveUrl } from './secure-print.js';
6
6
 
@@ -97,9 +97,17 @@ const HTML_CONTENT = `
97
97
  let username = isHost ? 'Host' : prompt('Enter your name for the chat:', 'Client_' + Math.floor(Math.random()*1000));
98
98
  if (!username) username = 'Anonymous';
99
99
 
100
+ function showBodyMessage(text, color) {
101
+ const message = document.createElement('h2');
102
+ message.style.cssText = 'text-align:center;margin-top:20vh';
103
+ if (color) message.style.color = color;
104
+ message.textContent = text;
105
+ document.body.replaceChildren(message);
106
+ }
107
+
100
108
  const sessionPassword = window.location.hash.substring(1);
101
109
  if (!sessionPassword) {
102
- document.body.innerHTML = '<h2 style="text-align:center; margin-top:20vh; color:red;">Fatal: Missing session password in URL hash. Cannot decrypt E2E chat.</h2>';
110
+ showBodyMessage('Fatal: Missing session password in URL hash. Cannot decrypt E2E chat.', 'red');
103
111
  throw new Error("Missing password");
104
112
  }
105
113
 
@@ -168,14 +176,20 @@ const HTML_CONTENT = `
168
176
  div.textContent = msg.text;
169
177
  } else {
170
178
  div.className = 'message ' + (msg.sender === username ? 'self' : 'other');
171
- div.innerHTML = '<div class="message-header">' + msg.sender + ' • ' + msg.time + '</div><div>' + msg.text + '</div>';
179
+ const header = document.createElement('div');
180
+ header.className = 'message-header';
181
+ header.textContent = String(msg.sender || 'Unknown') + ' • ' + String(msg.time || '');
182
+ const body = document.createElement('div');
183
+ body.textContent = String(msg.text || '');
184
+ div.appendChild(header);
185
+ div.appendChild(body);
172
186
  }
173
187
  msgs.appendChild(div);
174
188
  msgs.scrollTop = msgs.scrollHeight;
175
189
  }
176
190
 
177
191
  function updateUsers(users) {
178
- usersList.innerHTML = '';
192
+ usersList.replaceChildren();
179
193
  users.forEach(u => {
180
194
  const li = document.createElement('li');
181
195
  li.className = 'user-item';
@@ -246,7 +260,7 @@ const HTML_CONTENT = `
246
260
  } else {
247
261
  ws.close();
248
262
  window.close();
249
- document.body.innerHTML = '<h2 style="text-align:center; margin-top:20vh;">You have left the chat. You can close this tab.</h2>';
263
+ showBodyMessage('You have left the chat. You can close this tab.');
250
264
  }
251
265
  };
252
266
  </script>
@@ -321,7 +335,7 @@ export async function startChatServer(onClose) {
321
335
  export async function openLocalChatUI(port, password) {
322
336
  try {
323
337
  const chatUrl = `http://localhost:${port}#${password}`;
324
- await open(chatUrl);
338
+ await openUrl(chatUrl);
325
339
  } catch {
326
340
  console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
327
341
  }
@@ -1,12 +1,32 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import { canUseWorkers, runWorkerTask } from './worker-runtime.js';
3
4
 
4
- export async function calculateChecksum(filePath) {
5
+ const WORKER_CHECKSUM_THRESHOLD_BYTES = 2 * 1024 * 1024;
6
+
7
+ async function calculateChecksumStream(filePath) {
5
8
  return new Promise((resolve) => {
6
9
  const hash = crypto.createHash('sha256');
7
10
  const stream = fs.createReadStream(filePath);
8
11
  stream.on('error', () => resolve(null));
9
- stream.on('data', chunk => hash.update(chunk));
12
+ stream.on('data', (chunk) => hash.update(chunk));
10
13
  stream.on('end', () => resolve(hash.digest('hex')));
11
14
  });
12
15
  }
16
+
17
+ export async function calculateChecksum(filePath) {
18
+ const stat = await fs.promises.stat(filePath).catch(() => null);
19
+ if (!stat || !stat.isFile()) return null;
20
+
21
+ if (canUseWorkers() && stat.size >= WORKER_CHECKSUM_THRESHOLD_BYTES) {
22
+ try {
23
+ const result = await runWorkerTask('checksum', { filePath });
24
+ return result.digest || null;
25
+ } catch (err) {
26
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
27
+ return calculateChecksumStream(filePath);
28
+ }
29
+ }
30
+
31
+ return calculateChecksumStream(filePath);
32
+ }