diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index a1bac8296..bdda3516c 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -126,6 +126,53 @@ describe('detectProviderImportCandidates', () => { }); }); + it('reads decorated reseller messages (math-alphabet labels, arrow separators)', () => { + // Real-world shape: labels dressed in Unicode math monospace to + // slip past chat spam filters, ➤ as the separator, ├◉ decoration. + const candidates = detectProviderImportCandidates( + [ + '◉𝙿𝙾𝚁𝚃𝙰𝙻➤ http://tv.example.nl:80/', + '├◉𝚄𝚂𝙴𝚁➤ 6b13aa64d3', + '├◉𝙿𝙰𝚂𝚂➤ 602bb462ab75', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://tv.example.nl', + username: '6b13aa64d3', + password: '602bb462ab75', + }); + }); + + it.each([ + ['arrow', 'USER ➤ carol\nPASS ➤ pa55'], + ['triangle', 'User ► carol\nPass ► pa55'], + ['unicode arrow', 'login → carol\npassword → pa55'], + ['ascii gt', 'USER > carol\nPASS > pa55'], + ['spaced dash', 'User - carol\nPass - pa55'], + ])( + 'accepts the %s label separator', + (_variant, message) => { + const xtream = only( + detectProviderImportCandidates(message), + 'xtream' + ); + expect(xtream).toHaveLength(1); + expect(xtream[0].username).toBe('carol'); + expect(xtream[0].password).toBe('pa55'); + } + ); + + it('does not read a hyphenated word as a labeled value', () => { + expect( + detectProviderImportCandidates( + 'Our user-friendly setup takes a minute.' + ) + ).toEqual([]); + }); + it('treats credentials in the query of a generic URL as xtream', () => { const candidates = detectProviderImportCandidates( 'http://portal.example.io/api?username=eve&password=pw&foo=bar' diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts index 1db9b76cc..220089107 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -17,10 +17,15 @@ import { * * Design constraints, in order: * - * 1. **Every value is a verbatim substring of the input** (or a canonical - * form produced by an existing shared normalizer such as + * 1. **Every value is a verbatim substring of the NFKC-normalized input** + * (or a canonical form produced by an existing shared normalizer such as * `normalizeStalkerMacAddress`). Nothing here invents, completes, or - * "fixes" a credential — a password that is almost right is wrong. + * "fixes" a credential — a password that is almost right is wrong. NFKC + * is applied once up front because reseller messages dress labels in + * Unicode math alphabets (`𝚄𝚂𝙴𝚁➤`, `𝙿𝙰𝚂𝚂➤`) to slip past chat spam + * filters; the folding turns them back into the ASCII they denote, and + * real credentials/URLs are ASCII already, so values pass through it + * unchanged. * 2. **Detection proposes, it never decides.** The output prefills the * existing import forms; classification authority stays with the * behavioral probes those forms already run (portal discovery, Xtream @@ -79,14 +84,18 @@ export function detectProviderImportCandidates( // A raw playlist body pasted wholesale: every URL inside is a channel // stream, not a source, so URL mining would only produce noise. Route the - // whole paste to the raw-text import instead. + // whole paste to the raw-text import instead — the ORIGINAL bytes, since + // a playlist body is content, not decoration to be folded. if (EXTM3U_HEADER_PATTERN.test(raw)) { return [{ kind: 'm3u-text', confidence: 'high', text: raw }]; } - const urls = extractUrls(raw); - const labeled = extractLabeledFields(raw, urls); - const macs = extractMacAddresses(raw, labeled.macAddress); + // All scanning runs on one consistently normalized string so label + // positions, URL spans and masking agree with each other. + const normalized = raw.normalize('NFKC'); + const urls = extractUrls(normalized); + const labeled = extractLabeledFields(normalized, urls); + const macs = extractMacAddresses(normalized, labeled.macAddress); const candidates: ProviderImportCandidate[] = []; // ── Stalker: a MAC address is the strongest signal there is. Labeled diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index 14365bd9f..8a73e6eef 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -61,71 +61,95 @@ interface FieldMatcher { validate?: (value: string) => boolean; } +/** A label must not continue a word ("sublogin" is not a login label). */ +const LABEL_LOOKBEHIND = String.raw`(?` any run of arrow/geometric/dingbat glyphs (U+2190–21FF, + * U+25A0–25FF, U+2600–27BF, U+2B00–2BFF) counts. A dash counts only when it + * stands alone between spaces: "PASS - abc" is a separator, "user-friendly" + * is prose. Some symbol IS still required — a bare "login example" in prose + * must keep failing to match. + */ +const LABEL_SEPARATOR = String.raw`(?:\s*(?:[:=>]|[←-⇿■-◿☀-➿⬀-⯿])+\s*|\s+[-–—]+\s+)`; + +const DEFAULT_VALUE = String.raw`[^\s,;|]+`; + +function labeledPattern(labels: string, value = DEFAULT_VALUE): RegExp { + return new RegExp( + `${LABEL_LOOKBEHIND}(?:${labels})${LABEL_SEPARATOR}(${value})`, + 'iu' + ); +} + +const isHexIdentity = (value: string) => HEX_IDENTITY_PATTERN.test(value); + // Order matters only for readability — each matcher targets its own field and // the numbered variants ("device id 2") are structured so the unnumbered // pattern cannot swallow them (its optional `1` never matches a literal `2`, -// and the required `:`/`=` separator then fails on the digit). +// and the required separator then fails on the digit). // -// All matchers require an explicit `:` or `=` separator. Provider messages -// overwhelmingly use "Label: value"; a bare "login example" in prose is -// exactly the false positive the separator rule exists to reject. The label -// vocabulary is deliberately small (English + Russian) until a corpus of -// real provider messages justifies growing it. +// The label vocabulary is deliberately small (English + Russian) until a +// corpus of real provider messages justifies growing it. Callers hand this +// table NFKC-normalized text, which is what folds the decorative +// math-alphabet labels (`𝚄𝚂𝙴𝚁`, `𝙿𝙰𝚂𝚂`) into the plain ASCII matched here. const FIELD_MATCHERS: FieldMatcher[] = [ { field: 'deviceId2', - pattern: - /(? HEX_IDENTITY_PATTERN.test(value), + pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*2`), + validate: isHexIdentity, }, { field: 'deviceId1', - pattern: - /(? HEX_IDENTITY_PATTERN.test(value), + pattern: labeledPattern(String.raw`device[\s_-]*id[\s_-]*1?`), + validate: isHexIdentity, }, { field: 'signature2', - pattern: - /(? HEX_IDENTITY_PATTERN.test(value), + pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*2`), + validate: isHexIdentity, }, { field: 'signature1', - pattern: - /(? HEX_IDENTITY_PATTERN.test(value), + pattern: labeledPattern(String.raw`sig(?:nature)?[\s_-]*1?`), + validate: isHexIdentity, }, { field: 'serialNumber', - pattern: - /(? value.length <= 64, }, { field: 'macAddress', - pattern: - /(? normalizeStalkerMacAddress(value) !== null, }, { field: 'username', - pattern: - /(?