diff --git a/src/entrypoints/content/index.ts b/src/entrypoints/content/index.ts index a2b87da..8c05540 100644 --- a/src/entrypoints/content/index.ts +++ b/src/entrypoints/content/index.ts @@ -1,11 +1,12 @@ import { i18n } from '#imports' import { defineContentScript } from 'wxt/utils/define-content-script' -import { Hosts, findBestWildcardMatch } from '@/utils/hosts.ts' +import { Hosts } from '@/utils/hosts.ts' // TODO: Logging let url: URL let tabEnabled = false +let lastCreds: string | undefined export default defineContentScript({ matches: ['*://*/*'], @@ -27,32 +28,15 @@ export default defineContentScript({ async function onChanged(changes: Record) { // console.debug('content/index.ts - onChanged:', changes) - const exactItems = changes[url.host[0]] // NOTE: Lazy Typing... in changes - const wildcardItems = changes['*'] - - if (!exactItems && !wildcardItems) return - - if (exactItems) { - const oldCreds = exactItems.oldValue?.[url.host] - const newCreds = exactItems.newValue?.[url.host] - if (oldCreds !== newCreds) { - // If exact match was removed, check if a wildcard still covers this host - if (!newCreds) { - const wildcard = findBestWildcardMatch(url.host, await Hosts.all()) - return await processCreds(wildcard) - } - return await processCreds(newCreds) - } - } - - if (wildcardItems) { - const oldWildcard = findBestWildcardMatch(url.host, wildcardItems.oldValue) - const newWildcard = findBestWildcardMatch(url.host, wildcardItems.newValue) - if (oldWildcard !== newWildcard) await processCreds(newWildcard) - } + // NOTE: Only these buckets can affect the current host (exact entries + wildcards) + if (!(url.host[0] in changes) && !('*' in changes)) return + const creds = await Hosts.get(url.host) + if (creds === lastCreds) return + await processCreds(creds) } async function processCreds(creds: any) { + lastCreds = creds // console.debug('processCreds - tabEnabled:', tabEnabled, '- creds:', creds) try { if (creds) { diff --git a/src/utils/hosts.ts b/src/utils/hosts.ts index 42ef336..e6a955e 100644 --- a/src/utils/hosts.ts +++ b/src/utils/hosts.ts @@ -75,7 +75,7 @@ export function validateHostname(hostname: string): string | undefined { const segments = hostPart.split('.') if (segments.length === 0 || segments.includes('')) return undefined for (const segment of segments) { - if (segment === '*') continue + if (segment === '*' || segment === '**') continue if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(segment)) return undefined } @@ -92,11 +92,29 @@ export function matchesWildcard(host: string, pattern: string): boolean { const hostParts = hostName.split('.') const patternParts = patternName.split('.') - if (patternParts.length !== hostParts.length) return false - return patternParts.every((part, i) => - part === '*' ? (hostParts[i]?.length ?? 0) > 0 : part === hostParts[i], - ) + return matchSegments(hostParts, patternParts) +} + +// NOTE: `*` matches a single label, `**` matches one or more labels +function matchSegments(hostParts: string[], patternParts: string[]): boolean { + const match = (hostIndex: number, patternIndex: number): boolean => { + if (patternIndex === patternParts.length) return hostIndex === hostParts.length + const part = patternParts[patternIndex] + if (part === '**') { + for (let end = hostIndex + 1; end <= hostParts.length; end++) { + if (match(end, patternIndex + 1)) return true + } + return false + } + if (hostIndex >= hostParts.length) return false + if (part === '*') + return ( + (hostParts[hostIndex]?.length ?? 0) > 0 && match(hostIndex + 1, patternIndex + 1) + ) + return hostParts[hostIndex] === part && match(hostIndex + 1, patternIndex + 1) + } + return match(0, 0) } export function findBestWildcardMatch( @@ -113,12 +131,12 @@ function findBestWildcard( if (!patterns) return undefined let bestKey: string | undefined let bestCreds: string | undefined - let bestSpecificity = Infinity + let bestSpecificity = -1 for (const [pattern, creds] of Object.entries(patterns)) { if (!pattern.includes('*')) continue if (matchesWildcard(host, pattern)) { - const specificity = countWildcards(pattern) - if (specificity < bestSpecificity) { + const specificity = wildcardSpecificity(pattern) + if (specificity > bestSpecificity) { bestSpecificity = specificity bestKey = pattern bestCreds = creds @@ -135,6 +153,11 @@ function parseHostPort(value: string): [string, string | undefined] { : [value.slice(0, colon), value.slice(colon + 1)] } -function countWildcards(pattern: string): number { - return (pattern.match(/\*/g) || []).length +// NOTE: Exact labels are most specific, then `*`, then `**` +function wildcardSpecificity(pattern: string): number { + return pattern.split('.').reduce((score, segment) => { + if (segment === '**') return score + if (segment === '*') return score + 1 + return score + 2 + }, 0) } diff --git a/tests/test-validate-hostname.ts b/tests/test-validate-hostname.ts index d3c2a51..197c977 100644 --- a/tests/test-validate-hostname.ts +++ b/tests/test-validate-hostname.ts @@ -11,6 +11,11 @@ const validTests: [string, string][] = [ ['localhost', 'localhost'], ['Example.COM', 'example.com'], [' Example.COM ', 'example.com'], + ['*.example.com', '*.example.com'], + ['staging.**', 'staging.**'], + ['staging.**.example.com', 'staging.**.example.com'], + ['**.example.com', '**.example.com'], + ['staging.**:8080', 'staging.**:8080'], ] const invalidTests: string[] = [ @@ -37,6 +42,7 @@ const invalidTests: string[] = [ '`example.com', 'example.com:abc', 'example..com', + 'example.***.com', '.', '-', ] diff --git a/tests/test-wildcard.ts b/tests/test-wildcard.ts index f695763..72c3382 100644 --- a/tests/test-wildcard.ts +++ b/tests/test-wildcard.ts @@ -1,4 +1,4 @@ -import { matchesWildcard } from '@/utils/hosts.ts' +import { matchesWildcard, findBestWildcardMatch } from '@/utils/hosts.ts' const tests: [string, string, boolean][] = [ ['sub.example.com', '*.example.com', true], @@ -14,6 +14,31 @@ const tests: [string, string, boolean][] = [ ['example.com', '*', false], ['sub.example.com:8080', '*.example.com:*', true], ['sub.example.com:8080', '*.example.com:9090', false], + ['staging.a', 'staging.**', true], + ['staging.a.b', 'staging.**', true], + ['staging.a.b.c.d', 'staging.**', true], + ['staging', 'staging.**', false], + ['staging.a', 'staging.**.com', false], + ['a.example.com', '**.example.com', true], + ['a.b.example.com', '**.example.com', true], + ['example.com', '**.example.com', false], + ['a.b.example.com:8080', '**.example.com:*', true], + ['a.example.com', 'a.**.com', true], + ['a.b.c.example.com', 'a.**.com', true], + ['a.example.com', 'a.**.net', false], +] + +const bestTests: [string, Record, string | undefined][] = [ + ['staging.a', { 'staging.**': 'user1:pass', 'staging.*': 'user2:pass' }, 'user2:pass'], + ['staging.a.b', { 'staging.**': 'user1:pass' }, 'user1:pass'], + ['staging', { 'staging.**': 'user1:pass' }, undefined], + ['example.com', { '**.com': 'user1:pass' }, 'user1:pass'], + ['a.example.com', { '**.com': 'user1:pass' }, 'user1:pass'], + [ + 'a.example.com', + { '*.example.com': 'user2:pass', '**.example.com': 'user1:pass' }, + 'user2:pass', + ], ] for (const [host, pattern, expected] of tests) { @@ -21,3 +46,12 @@ for (const [host, pattern, expected] of tests) { const status = result === expected ? '' : '⛔ FAIL ⛔' console.log(`${pattern.padEnd(19)} ${expected ? '✅' : '❌'} ${host} ${status}`) } + +console.log('\nBest match:') +for (const [host, patterns, expected] of bestTests) { + const result = findBestWildcardMatch(host, patterns) + const status = result === expected ? '' : '⛔ FAIL ⛔' + console.log( + `${host.padEnd(24)} ${expected ?? 'none'.padEnd(10)} ${JSON.stringify(patterns)} ${status}`, + ) +}