@fanboynz/network-scanner 2.0.66 → 3.0.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.
@@ -0,0 +1,137 @@
1
+ // === Shared async-spawn helper ===
2
+ // Single canonical implementation of "spawn an external command, collect
3
+ // stdout/stderr with hard caps, enforce a kill timeout" — extracted from
4
+ // what used to be ~400 lines of near-identical Promise wrappers across
5
+ // lib/curl.js, lib/searchstring.js, and lib/grep.js (two sites in the
6
+ // latter). Each caller previously had its own copy of the same pattern:
7
+ // truncate-on-cap, SIGKILL belt-and-braces timer, stdout buffer concat,
8
+ // error/close handlers.
9
+ //
10
+ // Design notes:
11
+ // - Resolves (never rejects). Callers inspect the result object to
12
+ // distinguish failure modes instead of needing try/catch. Pre-existing
13
+ // call sites all converted failure into a result-object anyway —
14
+ // this just standardizes the shape.
15
+ // - stdout/stderr returned as Buffer so the caller decides decoding
16
+ // (curl's `--write-out` metadata is line-oriented and tolerant of
17
+ // UTF-8 decoding mid-multibyte; binary HTTP responses are not).
18
+ // - lib/wireguard_vpn.js intentionally stays on spawnSync — its calls
19
+ // are startup-only (validation, health check) and the synchronous
20
+ // semantics are simpler. Not all spawn calls benefit from async.
21
+
22
+ const { spawn } = require('child_process');
23
+
24
+ const DEFAULT_TIMEOUT_MS = 30000;
25
+ const DEFAULT_MAX_STDOUT = 50 * 1024 * 1024; // 50MB
26
+ const KILL_GRACE_MS = 5000; // belt-and-braces SIGKILL after timeout+grace
27
+
28
+ /**
29
+ * Spawn a process asynchronously, collect stdout/stderr with hard caps,
30
+ * enforce a kill timeout. Resolves with a result object describing what
31
+ * happened — never rejects.
32
+ *
33
+ * @param {string} cmd - Executable name (no shell — args are not parsed)
34
+ * @param {string[]} args - Argument array
35
+ * @param {object} [opts]
36
+ * @param {number} [opts.timeout=30000] - Soft timeout in ms; primary
37
+ * limit is whatever the executable itself enforces (e.g. curl
38
+ * --max-time). This is the belt-and-braces SIGKILL deadline fired at
39
+ * `timeout + 5000` ms.
40
+ * @param {number} [opts.maxStdout=52428800] - Cap on stdout collection.
41
+ * When the cap is exceeded the child is killed with SIGTERM and the
42
+ * result has `truncated: true`.
43
+ * @param {string|Buffer} [opts.input] - Data to write to the child's
44
+ * stdin then close. EPIPE on stdin is swallowed (child may exit early).
45
+ * @param {boolean} [opts.collectStderr=true] - When false, stderr is
46
+ * drained but not retained (saves memory when caller doesn't need it).
47
+ * @returns {Promise<{
48
+ * code: number|null,
49
+ * signal: string|null,
50
+ * stdout: Buffer,
51
+ * stderr: Buffer,
52
+ * truncated: boolean,
53
+ * error: string|null
54
+ * }>}
55
+ */
56
+ function runProcess(cmd, args, opts = {}) {
57
+ const {
58
+ timeout = DEFAULT_TIMEOUT_MS,
59
+ maxStdout = DEFAULT_MAX_STDOUT,
60
+ input,
61
+ collectStderr = true
62
+ } = opts;
63
+
64
+ return new Promise((resolve) => {
65
+ let child;
66
+ try {
67
+ child = spawn(cmd, args);
68
+ } catch (spawnErr) {
69
+ resolve({
70
+ code: null, signal: null,
71
+ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
72
+ truncated: false, error: spawnErr.message
73
+ });
74
+ return;
75
+ }
76
+
77
+ const stdoutChunks = [];
78
+ const stderrChunks = [];
79
+ let stdoutBytes = 0;
80
+ let truncated = false;
81
+
82
+ child.stdout.on('data', (chunk) => {
83
+ if (truncated) return;
84
+ if (stdoutBytes + chunk.length > maxStdout) {
85
+ truncated = true;
86
+ try { child.kill('SIGTERM'); } catch (_) {}
87
+ return;
88
+ }
89
+ stdoutBytes += chunk.length;
90
+ stdoutChunks.push(chunk);
91
+ });
92
+
93
+ if (collectStderr) {
94
+ child.stderr.on('data', (chunk) => { stderrChunks.push(chunk); });
95
+ } else {
96
+ child.stderr.on('data', () => {}); // drain but discard
97
+ }
98
+
99
+ // SIGKILL belt-and-braces after timeout+grace. unref'd so the timer
100
+ // doesn't keep the event loop alive on its own; if the process exits
101
+ // earlier, the timer is cleared in the close handler.
102
+ const killTimer = setTimeout(() => {
103
+ try { child.kill('SIGKILL'); } catch (_) {}
104
+ }, timeout + KILL_GRACE_MS);
105
+ if (typeof killTimer.unref === 'function') killTimer.unref();
106
+
107
+ child.on('error', (err) => {
108
+ clearTimeout(killTimer);
109
+ resolve({
110
+ code: null, signal: null,
111
+ stdout: Buffer.concat(stdoutChunks),
112
+ stderr: Buffer.concat(stderrChunks),
113
+ truncated, error: err.message
114
+ });
115
+ });
116
+
117
+ child.on('close', (code, signal) => {
118
+ clearTimeout(killTimer);
119
+ resolve({
120
+ code, signal,
121
+ stdout: Buffer.concat(stdoutChunks),
122
+ stderr: Buffer.concat(stderrChunks),
123
+ truncated, error: null
124
+ });
125
+ });
126
+
127
+ if (input !== undefined) {
128
+ // EPIPE if the child exited before we finished writing (e.g. grep
129
+ // matched and bailed early, or our truncation kill fired). Swallow
130
+ // so it doesn't surface as an unhandled stream error.
131
+ child.stdin.on('error', () => {});
132
+ child.stdin.end(input);
133
+ }
134
+ });
135
+ }
136
+
137
+ module.exports = { runProcess };
@@ -1,10 +1,45 @@
1
- const { formatLogMessage } = require('./colorize');
1
+ const net = require('node:net');
2
+ const { formatLogMessage, messageColors } = require('./colorize');
3
+ // Cross-module validators wired into site-config validation — previously
4
+ // each had to be called separately (or wasn't called at all). Centralizing
5
+ // here means a single validateSiteConfig surfaces ALL misconfigurations
6
+ // at startup instead of mid-scan.
7
+ // - validateSearchString: had ZERO callers anywhere before this hookup.
8
+ // - validateVpnConfig / validateOvpnConfig: called inside connectForSite
9
+ // per-site at scan time. Adding here catches errors at startup.
10
+ const { validateSearchString } = require('./searchstring');
11
+ const { validateVpnConfig: validateWgConfig, normalizeVpnConfig: normalizeWgConfig } = require('./wireguard_vpn');
12
+ const { validateOvpnConfig, normalizeOvpnConfig } = require('./openvpn_vpn');
13
+ const CLEAN_TAG = messageColors.processing('[clean]');
2
14
 
3
- // Pre-compiled regex constants for validation
15
+ // Pre-compiled regex constants for validation. IPv4/IPv6 validation now
16
+ // uses Node's built-in net.isIP() — the old hand-rolled regexes were
17
+ // incomplete (missing IPv4-mapped IPv6, zone identifiers, etc.) and
18
+ // silently accepted some malformed inputs like '2001:db8:::1'.
4
19
  const REGEX_LABEL = /^[a-zA-Z0-9-]+$/;
5
20
  const REGEX_TLD = /^[a-zA-Z][a-zA-Z0-9]*$/;
6
- const REGEX_IPv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
7
- const REGEX_IPv6 = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^::1$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$/;
21
+
22
+ // Module-level Set of valid adblock filter modifiers. Was previously
23
+ // re-allocated inside validateAdblockModifiers on every call — for a
24
+ // 100k-line filter list that's 100k identical Set allocations.
25
+ const VALID_MODIFIERS = new Set([
26
+ // Resource type modifiers
27
+ 'script', 'stylesheet', 'image', 'object', 'xmlhttprequest', 'subdocument',
28
+ 'ping', 'websocket', 'webrtc', 'document', 'elemhide', 'generichide',
29
+ 'genericblock', 'popup', 'font', 'media', 'other',
30
+ // Party modifiers
31
+ 'third-party', 'first-party', '~third-party', '~first-party',
32
+ // Domain modifiers (domain= is validated separately below)
33
+ 'domain',
34
+ // Method modifiers
35
+ 'match-case', '~match-case',
36
+ // Action modifiers
37
+ 'important', 'badfilter',
38
+ // CSP and redirect modifiers
39
+ 'csp', 'redirect', 'redirect-rule',
40
+ // uBlock Origin specific
41
+ 'inline-script', 'inline-font', 'mp4', 'empty', 'xhr'
42
+ ]);
8
43
 
9
44
  /**
10
45
  * Enhanced domain validation function
@@ -128,30 +163,31 @@ function isValidTLD(tld) {
128
163
  }
129
164
 
130
165
  /**
131
- * Checks if a string is an IP address (IPv4 or IPv6)
166
+ * Checks if a string is an IP address (IPv4 or IPv6).
167
+ * Delegates to Node's net.isIP() — standards-compliant, no regex to
168
+ * maintain. Returns true for any valid IP form including IPv4-mapped
169
+ * IPv6 (::ffff:192.0.2.1) which the old hand-rolled regex missed.
132
170
  * @param {string} str - String to check
133
171
  * @returns {boolean} True if it's an IP address
134
172
  */
135
173
  function isIPAddress(str) {
136
- return isIPv4(str) || isIPv6(str);
174
+ return net.isIP(str) !== 0;
137
175
  }
138
176
 
139
177
  /**
140
- * Checks if a string is a valid IPv4 address
141
- * @param {string} str - String to check
178
+ * @param {string} str
142
179
  * @returns {boolean} True if valid IPv4
143
180
  */
144
181
  function isIPv4(str) {
145
- return REGEX_IPv4.test(str);
182
+ return net.isIPv4(str);
146
183
  }
147
184
 
148
185
  /**
149
- * Checks if a string is a valid IPv6 address
150
- * @param {string} str - String to check
186
+ * @param {string} str
151
187
  * @returns {boolean} True if valid IPv6
152
188
  */
153
189
  function isIPv6(str) {
154
- return REGEX_IPv6.test(str);
190
+ return net.isIPv6(str);
155
191
  }
156
192
 
157
193
  /**
@@ -163,11 +199,19 @@ function validateRegexPattern(pattern) {
163
199
  if (!pattern || typeof pattern !== 'string') {
164
200
  return { isValid: false, error: 'Pattern must be a non-empty string' };
165
201
  }
166
-
202
+
167
203
  try {
168
- // Remove leading/trailing slashes if present
169
- const cleanPattern = pattern.replace(/^\/(.*)\/$/, '$1');
170
- new RegExp(cleanPattern);
204
+ // Handle /pattern/flags literal syntax. The old `^\/(.*)\/$/` strip
205
+ // didn't match patterns with flags ('/foo/i'), so they passed through
206
+ // unchanged to `new RegExp('/foo/i')` — which compiled a regex that
207
+ // matched the LITERAL string '/foo/i' instead of the intended `foo`
208
+ // pattern with the `i` flag. Silent acceptance of malformed input.
209
+ const literalMatch = pattern.match(/^\/(.*)\/([gimsuy]*)$/);
210
+ if (literalMatch) {
211
+ new RegExp(literalMatch[1], literalMatch[2]);
212
+ } else {
213
+ new RegExp(pattern);
214
+ }
171
215
  return { isValid: true };
172
216
  } catch (err) {
173
217
  return { isValid: false, error: `Invalid regex: ${err.message}` };
@@ -183,33 +227,7 @@ function validateAdblockModifiers(modifiers) {
183
227
  if (!modifiers) {
184
228
  return { isValid: true, modifiers: [] };
185
229
  }
186
-
187
- // Valid adblock filter modifiers
188
- const validModifiers = new Set([
189
- // Resource type modifiers
190
- 'script', 'stylesheet', 'image', 'object', 'xmlhttprequest', 'subdocument',
191
- 'ping', 'websocket', 'webrtc', 'document', 'elemhide', 'generichide',
192
- 'genericblock', 'popup', 'font', 'media', 'other',
193
-
194
- // Party modifiers
195
- 'third-party', 'first-party', '~third-party', '~first-party',
196
-
197
- // Domain modifiers (domain= will be validated separately)
198
- 'domain',
199
-
200
- // Method modifiers
201
- 'match-case', '~match-case',
202
-
203
- // Action modifiers
204
- 'important', 'badfilter',
205
-
206
- // CSP and redirect modifiers
207
- 'csp', 'redirect', 'redirect-rule',
208
-
209
- // uBlock Origin specific
210
- 'inline-script', 'inline-font', 'mp4', 'empty', 'xhr'
211
- ]);
212
-
230
+
213
231
  const modifierList = modifiers.split(',').map(m => m.trim());
214
232
  const invalidModifiers = [];
215
233
  const parsedModifiers = [];
@@ -262,7 +280,7 @@ function validateAdblockModifiers(modifiers) {
262
280
  const isNegated = modifier.startsWith('~');
263
281
  const baseModifier = isNegated ? modifier.substring(1) : modifier;
264
282
 
265
- if (validModifiers.has(modifier) || validModifiers.has(baseModifier)) {
283
+ if (VALID_MODIFIERS.has(modifier) || VALID_MODIFIERS.has(baseModifier)) {
266
284
  parsedModifiers.push({
267
285
  type: baseModifier,
268
286
  negated: isNegated,
@@ -296,91 +314,124 @@ function validateAdblockRule(rule) {
296
314
  if (!rule || typeof rule !== 'string') {
297
315
  return { isValid: false, format: 'unknown', error: 'Rule must be a non-empty string' };
298
316
  }
299
-
317
+
300
318
  const trimmedRule = rule.trim();
301
-
319
+
302
320
  // Skip comments
303
321
  if (trimmedRule.startsWith('!') || trimmedRule.startsWith('#')) {
304
322
  return { isValid: true, format: 'comment' };
305
323
  }
306
-
307
- // Adblock format: ||domain.com^ or ||domain.com^$script,third-party
308
- if (trimmedRule.startsWith('||') && trimmedRule.includes('^')) {
309
- const parts = trimmedRule.substring(2).split('^');
324
+
325
+ // Strip @@ exception (whitelist) prefix and run the rest of validation
326
+ // on the remainder. Exception rules are standard adblock syntax
327
+ // (e.g. '@@||example.com^', '@@||example.com^$image') and appear
328
+ // throughout real-world filter lists like EasyList — without this,
329
+ // `nwss --validate-rules easylist.txt` flagged every exception as
330
+ // 'Unrecognized rule format'. We attach `isException: true` to the
331
+ // result so downstream consumers can see the whitelist intent.
332
+ let isException = false;
333
+ let working = trimmedRule;
334
+ if (working.startsWith('@@')) {
335
+ isException = true;
336
+ working = working.substring(2);
337
+ if (!working) {
338
+ return { isValid: false, format: 'unknown', error: '@@ exception prefix with no rule body' };
339
+ }
340
+ }
341
+
342
+ // @@ only makes sense as a prefix for adblock-format rules. Bail
343
+ // early if it's prefixing something else (e.g. '@@127.0.0.1 host'
344
+ // is meaningless — localhost format has no exception concept).
345
+ if (isException &&
346
+ !(working.startsWith('||') && working.includes('^')) &&
347
+ !(working.includes('^$'))) {
348
+ return {
349
+ isValid: false,
350
+ format: 'unknown',
351
+ isException: true,
352
+ error: '@@ exception prefix only valid for adblock-format rules'
353
+ };
354
+ }
355
+
356
+ // Adblock format: ||domain.com^ or ||domain.com^$script,third-party.
357
+ // Uses `working` (post-@@-strip body) instead of `trimmedRule`.
358
+ const ruleBody = working;
359
+ if (ruleBody.startsWith('||') && ruleBody.includes('^')) {
360
+ const parts = ruleBody.substring(2).split('^');
310
361
  const domain = parts[0];
311
-
362
+
312
363
  if (!isValidDomain(domain)) {
313
- return { isValid: false, format: 'adblock', error: `Invalid domain in adblock rule: ${domain}` };
364
+ return { isValid: false, format: 'adblock', isException, error: `Invalid domain in adblock rule: ${domain}` };
314
365
  }
315
-
366
+
316
367
  // Check for modifiers after ^$
317
368
  let modifiers = '';
318
369
  let modifierValidation = { isValid: true, modifiers: [] };
319
-
370
+
320
371
  if (parts.length > 1 && parts[1].startsWith('$')) {
321
372
  modifiers = parts[1].substring(1);
322
373
  modifierValidation = validateAdblockModifiers(modifiers);
323
-
374
+
324
375
  if (!modifierValidation.isValid) {
325
- return {
326
- isValid: false,
327
- format: 'adblock',
376
+ return {
377
+ isValid: false,
378
+ format: 'adblock',
379
+ isException,
328
380
  error: `${modifierValidation.error} in rule: ${trimmedRule}`,
329
381
  domain,
330
382
  modifiers: modifierValidation.validModifiers || []
331
383
  };
332
384
  }
333
385
  }
334
-
335
- return {
336
- isValid: true,
337
- format: 'adblock',
386
+
387
+ return {
388
+ isValid: true,
389
+ format: 'adblock',
390
+ isException,
338
391
  domain,
339
392
  modifiers: modifierValidation.modifiers,
340
393
  hasModifiers: modifiers.length > 0
341
394
  };
342
395
  }
343
-
396
+
344
397
  // Basic adblock format without ||: domain.com^$modifier
345
- if (trimmedRule.includes('^') && trimmedRule.includes('$')) {
346
- const parts = trimmedRule.split('^$');
398
+ if (ruleBody.includes('^') && ruleBody.includes('$')) {
399
+ const parts = ruleBody.split('^$');
347
400
  if (parts.length === 2) {
348
401
  const domain = parts[0];
349
402
  const modifiers = parts[1];
350
-
403
+
351
404
  if (!isValidDomain(domain)) {
352
- return { isValid: false, format: 'adblock-basic', error: `Invalid domain in adblock rule: ${domain}` };
405
+ return { isValid: false, format: 'adblock-basic', isException, error: `Invalid domain in adblock rule: ${domain}` };
353
406
  }
354
-
407
+
355
408
  const modifierValidation = validateAdblockModifiers(modifiers);
356
409
  if (!modifierValidation.isValid) {
357
- return {
358
- isValid: false,
359
- format: 'adblock-basic',
410
+ return {
411
+ isValid: false,
412
+ format: 'adblock-basic',
413
+ isException,
360
414
  error: modifierValidation.error,
361
415
  domain
362
416
  };
363
417
  }
364
-
365
- return {
366
- isValid: true,
367
- format: 'adblock-basic',
418
+
419
+ return {
420
+ isValid: true,
421
+ format: 'adblock-basic',
422
+ isException,
368
423
  domain,
369
424
  modifiers: modifierValidation.modifiers
370
425
  };
371
426
  }
372
427
  }
373
428
 
374
- // Simple adblock format: ||domain.com^ (without modifiers)
375
- if (trimmedRule.startsWith('||') && trimmedRule.endsWith('^')) {
376
- const domain = trimmedRule.substring(2, trimmedRule.length - 1);
377
- if (isValidDomain(domain)) {
378
- return { isValid: true, format: 'adblock-simple', domain };
379
- } else {
380
- return { isValid: false, format: 'adblock-simple', error: `Invalid domain in adblock rule: ${domain}` };
381
- }
382
- }
383
-
429
+ // Removed: "Simple adblock format" branch for `||domain.com^` without
430
+ // modifiers. The main `||...^` branch above already handles this case
431
+ // (parts becomes ['domain.com', ''] on split, the empty modifier check
432
+ // falls through to the success return as format='adblock'). This branch
433
+ // was unreachable dead code.
434
+
384
435
  // Localhost format: 127.0.0.1 domain.com or 0.0.0.0 domain.com
385
436
  if (trimmedRule.match(/^(127\.0\.0\.1|0\.0\.0\.0)\s+/)) {
386
437
  const parts = trimmedRule.split(/\s+/);
@@ -665,6 +716,44 @@ function validateSiteConfig(siteConfig, siteIndex = 0) {
665
716
  }
666
717
  });
667
718
 
719
+ // Cross-module validation: searchstring/searchstring_and. validateSearchString
720
+ // catches things like both-defined-at-once (forbidden), empty arrays, length
721
+ // caps, non-string elements. Before this call was added, misconfigured
722
+ // searchstring values silently passed validation and only surfaced as
723
+ // runtime TypeErrors mid-scan.
724
+ if (siteConfig.searchstring !== undefined || siteConfig.searchstring_and !== undefined) {
725
+ const ssValidation = validateSearchString(siteConfig.searchstring, siteConfig.searchstring_and);
726
+ if (!ssValidation.isValid) {
727
+ errors.push(`Site ${siteIndex}: ${ssValidation.error}`);
728
+ }
729
+ }
730
+
731
+ // Cross-module validation: VPN configs. nwss.js dispatches on field
732
+ // presence — `vpn` → WireGuard, `openvpn` → OpenVPN. Both validators
733
+ // require a normalized config object, so normalize first. Previously
734
+ // VPN errors only surfaced inside connectForSite at scan time; now
735
+ // misconfigured configs fail loudly at startup.
736
+ if (siteConfig.vpn !== undefined && siteConfig.openvpn !== undefined) {
737
+ warnings.push(`Site ${siteIndex}: both 'vpn' (WireGuard) and 'openvpn' set — runtime dispatches to WireGuard, openvpn config will be ignored`);
738
+ }
739
+ if (siteConfig.vpn !== undefined) {
740
+ const normalized = normalizeWgConfig(siteConfig.vpn);
741
+ const vpnValidation = validateWgConfig(normalized);
742
+ if (!vpnValidation.isValid) {
743
+ vpnValidation.errors.forEach(e => errors.push(`Site ${siteIndex} (WireGuard): ${e}`));
744
+ }
745
+ // Validator warnings (e.g. "requires root") propagate too.
746
+ (vpnValidation.warnings || []).forEach(w => warnings.push(`Site ${siteIndex} (WireGuard): ${w}`));
747
+ }
748
+ if (siteConfig.openvpn !== undefined && siteConfig.vpn === undefined) {
749
+ const normalized = normalizeOvpnConfig(siteConfig.openvpn);
750
+ const ovpnValidation = validateOvpnConfig(normalized);
751
+ if (!ovpnValidation.isValid) {
752
+ ovpnValidation.errors.forEach(e => errors.push(`Site ${siteIndex} (OpenVPN): ${e}`));
753
+ }
754
+ (ovpnValidation.warnings || []).forEach(w => warnings.push(`Site ${siteIndex} (OpenVPN): ${w}`));
755
+ }
756
+
668
757
  // Validate ignore_similar_threshold
669
758
  if (siteConfig.ignore_similar_threshold !== undefined) {
670
759
  if (typeof siteConfig.ignore_similar_threshold !== 'number' ||
@@ -774,7 +863,7 @@ function cleanRulesetFile(filePath, outputPath = null, options = {}) {
774
863
  stats.duplicates++;
775
864
 
776
865
  if (forceDebug) {
777
- console.log(formatLogMessage('debug', `[clean] Removing duplicate line ${lineNumber}: ${trimmed}`));
866
+ console.log(formatLogMessage('debug', `${CLEAN_TAG} Removing duplicate line ${lineNumber}: ${trimmed}`));
778
867
  }
779
868
  continue; // Skip duplicate
780
869
  } else {
@@ -789,7 +878,7 @@ function cleanRulesetFile(filePath, outputPath = null, options = {}) {
789
878
  stats.invalid++;
790
879
 
791
880
  if (forceDebug) {
792
- console.log(formatLogMessage('debug', `[clean] Removing invalid line ${lineNumber}: ${trimmed} (${validation.error})`));
881
+ console.log(formatLogMessage('debug', `${CLEAN_TAG} Removing invalid line ${lineNumber}: ${trimmed} (${validation.error})`));
793
882
  }
794
883
  }
795
884
  }
@@ -986,92 +1075,17 @@ function testDomainValidation() {
986
1075
  return allPassed;
987
1076
  }
988
1077
 
989
- /**
990
- * Test adblock rule validation with known test cases
991
- * @returns {boolean} True if all tests pass
992
- */
993
- function testAdblockValidation() {
994
- const testCases = [
995
- // Valid rules
996
- { rule: '||example.com^', expected: true },
997
- { rule: '||example.com^$script', expected: true },
998
- { rule: '127.0.0.1 example.com', expected: true },
999
- { rule: 'local=/example.com/', expected: true },
1000
-
1001
- // Invalid rules
1002
- { rule: '', expected: false },
1003
- { rule: '||invalid..domain^', expected: false },
1004
- { rule: '||.example.com^', expected: false }
1005
- ];
1006
-
1007
- let allPassed = true;
1008
-
1009
- testCases.forEach(({ rule, expected }) => {
1010
- const result = validateAdblockRule(rule);
1011
- if (result.isValid !== expected) {
1012
- console.error(`Test failed for rule "${rule}": expected ${expected}, got ${result.isValid}`);
1013
- allPassed = false;
1014
- }
1015
- });
1016
-
1017
- return allPassed;
1018
- }
1019
-
1020
- /**
1021
- * Validates a domain and formats it according to specified output options
1022
- * @param {string} domain - The domain to validate and format
1023
- * @param {object} options - Formatting options
1024
- * @returns {string|object} Formatted domain string or error object
1025
- */
1026
- function formatDomainWithValidation(domain, options = {}) {
1027
- const {
1028
- localhost = false,
1029
- localhostAlt = false,
1030
- plain = false,
1031
- dnsmasq = false,
1032
- dnsmasqOld = false,
1033
- unbound = false,
1034
- privoxy = false,
1035
- pihole = false,
1036
- adblockRules = false,
1037
- resourceType = ''
1038
- } = options;
1039
-
1040
- // Validate domain first
1041
- if (!isValidDomain(domain)) {
1042
- return {
1043
- isValid: false,
1044
- error: `Invalid domain format: ${domain}`,
1045
- formattedRule: null
1046
- };
1047
- }
1048
-
1049
- // Format according to specified options (priority order)
1050
- if (pihole) {
1051
- const escapedDomain = domain.replace(/\./g, '\\.');
1052
- return `(^|\\.)${escapedDomain}$`;
1053
- } else if (privoxy) {
1054
- return `{ +block } .${domain}`;
1055
- } else if (dnsmasq) {
1056
- return `local=/${domain}/`;
1057
- } else if (dnsmasqOld) {
1058
- return `server=/${domain}/`;
1059
- } else if (unbound) {
1060
- return `local-zone: "${domain}." always_null`;
1061
- } else if (localhost) {
1062
- return `127.0.0.1 ${domain}`;
1063
- } else if (localhostAlt) {
1064
- return `0.0.0.0 ${domain}`;
1065
- } else if (adblockRules && resourceType) {
1066
- return `||${domain}^$${resourceType}`;
1067
- } else if (plain) {
1068
- return domain;
1069
- } else {
1070
- // Default adblock format
1071
- return `||${domain}^`;
1072
- }
1073
- }
1074
-
1078
+ // Public surface used by nwss.js (validateRulesetFile, validateFullConfig,
1079
+ // testDomainValidation, cleanRulesetFile). The rest (isValidDomain,
1080
+ // isValidDomainLabel, isValidTLD, isIPAddress, isIPv4, isIPv6,
1081
+ // validateRegexPattern, validateAdblockModifiers, validateAdblockRule,
1082
+ // validateSiteConfig) stay internal-helper-but-exported for now since
1083
+ // downstream callers MAY import them via the dotted path even if grep
1084
+ // shows no current consumers — domain validators are the kind of thing
1085
+ // that gets added to in future. testAdblockValidation and
1086
+ // formatDomainWithValidation were removed entirely (zero callers
1087
+ // anywhere; formatDomainWithValidation looked like an old implementation
1088
+ // superseded by lib/output.js).
1075
1089
  module.exports = {
1076
1090
  isValidDomain,
1077
1091
  isValidDomainLabel,
@@ -1086,7 +1100,5 @@ module.exports = {
1086
1100
  cleanRulesetFile,
1087
1101
  validateSiteConfig,
1088
1102
  validateFullConfig,
1089
- testDomainValidation,
1090
- testAdblockValidation,
1091
- formatDomainWithValidation
1103
+ testDomainValidation
1092
1104
  };