tldts-tests 7.0.32 → 7.1.1

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.
@@ -1,8 +1,53 @@
1
1
  import { expect } from 'chai';
2
2
  import 'mocha';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { dirname, resolve } from 'path';
3
5
 
4
6
  import { loadPublicSuffixList, parsePublicSuffixRules } from 'tldts-utils';
5
7
 
8
+ /**
9
+ * A single entry of the WHATWG URL test data (web-platform-tests). Entries are
10
+ * either a comment string or a parsed-result object.
11
+ */
12
+ interface WhatwgUrlTestCase {
13
+ input: string;
14
+ base?: string | null;
15
+ hostname?: string;
16
+ protocol?: string;
17
+ failure?: boolean;
18
+ }
19
+
20
+ /** Locate a repo fixture whether tests run from `src` (ts-node) or `dist`. */
21
+ function resolveFixture(relativePath: string): string {
22
+ let dir = __dirname;
23
+ while (!existsSync(resolve(dir, relativePath)) && dir !== dirname(dir)) {
24
+ dir = dirname(dir);
25
+ }
26
+ return resolve(dir, relativePath);
27
+ }
28
+
29
+ /** True if `value` contains any non-ASCII (> U+007F) code unit. */
30
+ function containsNonAscii(value: string): boolean {
31
+ for (let i = 0; i < value.length; i += 1) {
32
+ if (value.charCodeAt(i) > 127) {
33
+ return true;
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * WHATWG URL test data, vendored verbatim from web-platform-tests:
41
+ * https://github.com/web-platform-tests/wpt/blob/258f285de043b79e44324228c0fd800b38d21879/url/resources/urltestdata.json
42
+ * Re-pin by re-downloading url/resources/urltestdata.json from that repo.
43
+ */
44
+ const WHATWG_URL_TEST_DATA = JSON.parse(
45
+ readFileSync(
46
+ resolveFixture('packages/tldts-tests/src/data/urltestdata.json'),
47
+ 'utf-8',
48
+ ),
49
+ ) as (string | WhatwgUrlTestCase)[];
50
+
6
51
  interface Options {
7
52
  extractHostname?: boolean;
8
53
  allowPrivateDomains?: boolean;
@@ -147,6 +192,11 @@ export default function test(
147
192
  'while',
148
193
  'with',
149
194
  'yield',
195
+ // Object.prototype-member labels (lowercase, so they survive hostname
196
+ // lowercasing): must be treated as ordinary labels, never resolve
197
+ // against the prototype chain during trie traversal.
198
+ 'constructor',
199
+ '__proto__',
150
200
  ].forEach((keyword) => {
151
201
  it(keyword, () => {
152
202
  expect(tldts.getDomain(`https://${keyword}.com`)).to.equal(
@@ -975,6 +1025,258 @@ export default function test(
975
1025
  }
976
1026
  });
977
1027
 
1028
+ // `getHostname` must pick the same host substring a compliant WHATWG parser
1029
+ // would, for tldts' scope. Intentional deviations (no IDNA/IPv4/IPv6
1030
+ // normalisation, trailing-dot stripping, lenient host:port/email) are pinned
1031
+ // in the "intentional deviations" block below.
1032
+ describe('WHATWG URL hostname compliance', () => {
1033
+ // --- Web Platform Tests: the browsers' own URL conformance corpus ---
1034
+ describe('web-platform-tests url/resources/urltestdata.json', () => {
1035
+ const SPECIAL_SCHEME = new Set([
1036
+ 'ftp:',
1037
+ 'file:',
1038
+ 'http:',
1039
+ 'https:',
1040
+ 'ws:',
1041
+ 'wss:',
1042
+ ]);
1043
+
1044
+ // In-scope = cases where tldts is expected to match the compliant parser
1045
+ // exactly. We exclude representation differences tldts does not perform
1046
+ // (IDN/punycode, IPv4 normalisation, IPv6) and inputs that rely on a base
1047
+ // URL (tldts has no base concept). `failure` cases are excluded because
1048
+ // tldts is best-effort, not strict-reject.
1049
+ const inScope = (o: WhatwgUrlTestCase): boolean => {
1050
+ if (o.failure) {
1051
+ return false;
1052
+ }
1053
+ const hostname = o.hostname;
1054
+ if (typeof hostname !== 'string') {
1055
+ return false;
1056
+ }
1057
+ if (containsNonAscii(o.input)) {
1058
+ return false; // non-ASCII input (IDN) — tldts keeps Unicode
1059
+ }
1060
+ if (hostname.startsWith('xn--')) {
1061
+ return false; // punycode output
1062
+ }
1063
+ if (hostname.startsWith('[')) {
1064
+ return false; // IPv6 (tldts strips brackets, does not compress)
1065
+ }
1066
+ if (o.protocol === 'file:') {
1067
+ return false; // file drive-letter / backslash quirks
1068
+ }
1069
+ if (/^[0-9.]+$/.test(hostname) && !o.input.includes(hostname)) {
1070
+ return false; // IPv4 normalisation (e.g. 0x7f.1 -> 127.0.0.1)
1071
+ }
1072
+ const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*:)\/\//.exec(o.input);
1073
+ if (schemeMatch !== null) {
1074
+ const scheme = schemeMatch[1];
1075
+ return (
1076
+ scheme !== undefined && SPECIAL_SCHEME.has(scheme.toLowerCase())
1077
+ );
1078
+ }
1079
+ // Bare input (no scheme, not a path) is only meaningful without a base.
1080
+ const bare =
1081
+ !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(o.input) &&
1082
+ !o.input.startsWith('/');
1083
+ return bare && (o.base === null || o.base === undefined);
1084
+ };
1085
+
1086
+ // tldts strips trailing dots, and reports "no host" as `null` (not "").
1087
+ const expectedHost = (hostname: string): string | null => {
1088
+ let host = hostname;
1089
+ while (host.length > 0 && host.endsWith('.')) {
1090
+ host = host.slice(0, -1);
1091
+ }
1092
+ return host === '' ? null : host;
1093
+ };
1094
+
1095
+ // Documented intentional deviations (NOT boundary bugs): each is an
1096
+ // IDNA / percent-decode / shallow-validation difference. Removing an entry
1097
+ // once tldts handles it becomes the regression gate.
1098
+ const KNOWN_DIVERGENCES = new Set<string>([
1099
+ 'http://./', // all-dot host: WHATWG collapses empty labels (IDNA); tldts keeps '.'
1100
+ 'http://../', // idem
1101
+ 'http://!"$&\'()*+,-.;=_`{}~/', // punctuation host: WHATWG allows, tldts shallow-validation rejects
1102
+ 'wss://!"$&\'()*+,-.;=_`{}~/', // idem
1103
+ 'https://a%C2%ADb/', // WHATWG percent-decodes + IDNA-maps the soft hyphen; tldts does neither
1104
+ ]);
1105
+
1106
+ for (const entry of WHATWG_URL_TEST_DATA) {
1107
+ if (typeof entry === 'string' || !inScope(entry)) {
1108
+ continue;
1109
+ }
1110
+ const { input } = entry;
1111
+ if (KNOWN_DIVERGENCES.has(input)) {
1112
+ continue;
1113
+ }
1114
+ const expected = expectedHost(entry.hostname!);
1115
+ it(JSON.stringify(input), () => {
1116
+ expect(tldts.getHostname(input), input).to.equal(expected);
1117
+ });
1118
+ }
1119
+ });
1120
+
1121
+ // --- Curated boundary cases (the bugs this change fixes) ---
1122
+ describe('boundary detection', () => {
1123
+ it("treats '\\' as '/' for special schemes", () => {
1124
+ // WHATWG host-state: backslash is a host terminator for special URLs.
1125
+ expect(tldts.getHostname('https:\\\\example.com\\path')).to.equal(
1126
+ 'example.com',
1127
+ );
1128
+ });
1129
+
1130
+ it("does not let a '\\' smuggle the host into userinfo", () => {
1131
+ // Security: 'http://example.com\@evil.com' must resolve to example.com,
1132
+ // not evil.com (backslash is normalised to '/' before the '@').
1133
+ expect(tldts.getHostname('http://example.com\\@evil.com')).to.equal(
1134
+ 'example.com',
1135
+ );
1136
+ });
1137
+
1138
+ it('parses a special scheme written without "//"', () => {
1139
+ // WHATWG special-authority-slashes state: the authority is parsed even
1140
+ // when the "//" is missing.
1141
+ expect(tldts.getHostname('https:example.com')).to.equal('example.com');
1142
+ expect(tldts.getHostname('http:example.com/foo')).to.equal(
1143
+ 'example.com',
1144
+ );
1145
+ });
1146
+
1147
+ it('removes an embedded ASCII tab in the scheme', () => {
1148
+ // WHATWG basic parser step 2: tab/newline are removed before parsing.
1149
+ expect(tldts.getHostname('htt\tp://example.com/')).to.equal(
1150
+ 'example.com',
1151
+ );
1152
+ });
1153
+
1154
+ it('removes an embedded newline / carriage return in the host', () => {
1155
+ expect(tldts.getHostname('http://exa\nmple.com/')).to.equal(
1156
+ 'example.com',
1157
+ );
1158
+ expect(tldts.getHostname('http://exa\rmple.com/')).to.equal(
1159
+ 'example.com',
1160
+ );
1161
+ });
1162
+
1163
+ it('returns null for file:/// (empty authority)', () => {
1164
+ // WHATWG file-host state: an empty buffer means an empty host.
1165
+ expect(tldts.getHostname('file:///etc/passwd')).to.equal(null);
1166
+ expect(tldts.getHostname('file:/etc/passwd')).to.equal(null);
1167
+ });
1168
+
1169
+ it('returns the host for file://host/path', () => {
1170
+ expect(tldts.getHostname('file://server/share')).to.equal('server');
1171
+ });
1172
+
1173
+ it('returns null for opaque (non-special, no "//") schemes', () => {
1174
+ // WHATWG opaque-path state: there is no host.
1175
+ expect(tldts.getHostname('foo:example.com/bar')).to.equal(null);
1176
+ expect(tldts.getHostname('javascript:alert(1)')).to.equal(null);
1177
+ expect(tldts.getHostname('urn:isbn:9780307476463')).to.equal(null);
1178
+ expect(tldts.getHostname('mailto:example.com')).to.equal(null); // no '@'
1179
+ });
1180
+
1181
+ it('extracts the host past an empty userinfo', () => {
1182
+ // WHATWG authority-state: 'http://@host' has empty userinfo, host=host.
1183
+ expect(tldts.getHostname('http://@example.com')).to.equal(
1184
+ 'example.com',
1185
+ );
1186
+ });
1187
+
1188
+ it('lower-cases the scheme when matching (case-insensitive)', () => {
1189
+ expect(tldts.getHostname('HTTPS:EXAMPLE.COM')).to.equal('example.com');
1190
+ });
1191
+
1192
+ it('recognises every special scheme written without "//"', () => {
1193
+ // Exercises the ws / wss / ftp / http / https / file classifier arms.
1194
+ expect(tldts.getHostname('ws:example.com')).to.equal('example.com');
1195
+ expect(tldts.getHostname('wss:example.com')).to.equal('example.com');
1196
+ expect(tldts.getHostname('ftp:example.com')).to.equal('example.com');
1197
+ expect(tldts.getHostname('file://h/x')).to.equal('h');
1198
+ });
1199
+
1200
+ it('treats look-alike non-special schemes as opaque (no host)', () => {
1201
+ // Scheme lengths 1..5 that all fail the special-scheme classifier.
1202
+ expect(tldts.getHostname('w:example.com')).to.equal(null);
1203
+ expect(tldts.getHostname('wx:example.com')).to.equal(null);
1204
+ expect(tldts.getHostname('htu:example.com')).to.equal(null);
1205
+ expect(tldts.getHostname('htxp:example.com')).to.equal(null);
1206
+ expect(tldts.getHostname('htxps:example.com')).to.equal(null);
1207
+ });
1208
+
1209
+ it('keeps a bare host:port (lenient superset)', () => {
1210
+ // tldts accepts hostnames, not only URLs; a strict parser treats the
1211
+ // left side as a scheme. The "port" must be numeric.
1212
+ expect(tldts.getHostname('example.co.uk:8080/p?q#h')).to.equal(
1213
+ 'example.co.uk',
1214
+ );
1215
+ });
1216
+
1217
+ it('treats host:<non-numeric-port> as an opaque scheme (=> null)', () => {
1218
+ // Documented limit of the host:port heuristic.
1219
+ expect(tldts.getHostname('example.com:foo')).to.equal(null);
1220
+ });
1221
+
1222
+ it('keeps a scheme-relative reference', () => {
1223
+ expect(tldts.getHostname('//user@example.co.uk:8080/x')).to.equal(
1224
+ 'example.co.uk',
1225
+ );
1226
+ });
1227
+ });
1228
+
1229
+ // --- Intentional deviations, pinned so they cannot change silently ---
1230
+ describe('intentional deviations (pinned)', () => {
1231
+ it('keeps Unicode hosts (no IDNA/punycode)', () => {
1232
+ expect(tldts.getHostname('http://bücher.example/')).to.equal(
1233
+ 'bücher.example',
1234
+ );
1235
+ });
1236
+
1237
+ it('keeps the literal IPv4 (no normalisation)', () => {
1238
+ expect(tldts.getHostname('http://0x7f.1/')).to.equal('0x7f.1');
1239
+ });
1240
+
1241
+ it('returns bracket-less, uncompressed IPv6', () => {
1242
+ expect(tldts.getHostname('http://[2001:0DB8::1]:8080/')).to.equal(
1243
+ '2001:0db8::1',
1244
+ );
1245
+ });
1246
+
1247
+ it('strips trailing dots', () => {
1248
+ expect(tldts.getHostname('http://example.com./')).to.equal(
1249
+ 'example.com',
1250
+ );
1251
+ });
1252
+
1253
+ it('is best-effort on an out-of-range port (WHATWG rejects)', () => {
1254
+ expect(tldts.getHostname('http://example.com:99999/')).to.equal(
1255
+ 'example.com',
1256
+ );
1257
+ });
1258
+
1259
+ it('extracts the host from an email-like input', () => {
1260
+ expect(tldts.getHostname('Pelé@example.com')).to.equal('example.com');
1261
+ });
1262
+
1263
+ it('keeps an all-dot host instead of collapsing it (no IDNA)', () => {
1264
+ expect(tldts.getHostname('http://./')).to.equal('.');
1265
+ expect(tldts.getHostname('http://../')).to.equal('.');
1266
+ });
1267
+
1268
+ it('rejects punctuation hosts WHATWG allows (shallow validation)', () => {
1269
+ expect(tldts.getHostname('http://!"$&\'()*+,-.;=_`{}~/')).to.equal(
1270
+ null,
1271
+ );
1272
+ });
1273
+
1274
+ it('does not percent-decode the host (no IDNA mapping)', () => {
1275
+ expect(tldts.getHostname('https://a%C2%ADb/')).to.equal(null);
1276
+ });
1277
+ });
1278
+ });
1279
+
978
1280
  describe('wildcard tests', () => {
979
1281
  parsePublicSuffixRules(loadPublicSuffixList(), (rule) => {
980
1282
  if (
@@ -1043,4 +1345,114 @@ export default function test(
1043
1345
  }
1044
1346
  });
1045
1347
  });
1348
+
1349
+ describe('options handling (regression for shared default-options reuse)', () => {
1350
+ // Calling a method with no options must behave identically to passing `{}`.
1351
+ // The no-options path now returns a shared cached defaults object instead of
1352
+ // allocating one per call; this guards that the shared object stays read-only.
1353
+ it('omitting options equals passing {} for every public method', () => {
1354
+ const url = 'https://sub.example.co.uk/path?q=1';
1355
+ expect(tldts.parse(url)).to.deep.equal(tldts.parse(url, {}));
1356
+ expect(tldts.getHostname(url)).to.equal(tldts.getHostname(url, {}));
1357
+ expect(tldts.getDomain(url)).to.equal(tldts.getDomain(url, {}));
1358
+ expect(tldts.getPublicSuffix(url)).to.equal(
1359
+ tldts.getPublicSuffix(url, {}),
1360
+ );
1361
+ expect(tldts.getSubdomain(url)).to.equal(tldts.getSubdomain(url, {}));
1362
+ expect(tldts.getDomainWithoutSuffix(url)).to.equal(
1363
+ tldts.getDomainWithoutSuffix(url, {}),
1364
+ );
1365
+ });
1366
+
1367
+ // A prior call with custom options must not corrupt the shared defaults that
1368
+ // a subsequent no-options call relies on (co.uk is ICANN in all packages).
1369
+ it('a custom-options call does not corrupt later default-options results', () => {
1370
+ const url = 'https://sub.example.co.uk/';
1371
+ const before = tldts.getDomain(url); // default options
1372
+ // interleave calls with assorted non-default options
1373
+ tldts.getDomain(url, {
1374
+ allowPrivateDomains: true,
1375
+ validateHostname: false,
1376
+ detectIp: false,
1377
+ });
1378
+ tldts.getDomain('https://other.example.com/', { mixedInputs: false });
1379
+ const after = tldts.getDomain(url); // default options again
1380
+ expect(after).to.equal(before);
1381
+ expect(after).to.equal('example.co.uk');
1382
+ });
1383
+ });
1384
+
1385
+ describe('public-suffix reconstruction (single-slice offset)', () => {
1386
+ // Rules match across multiple labels: the suffix is rebuilt via a single
1387
+ // hostname.slice at the computed offset (was parts.slice(i).join('.')).
1388
+ it('returns a multi-label rules suffix unchanged', () => {
1389
+ expect(tldts.getPublicSuffix('not.evil.co.uk')).to.equal('co.uk');
1390
+ expect(tldts.getDomain('a.b.c.example.co.uk')).to.equal('example.co.uk');
1391
+ });
1392
+
1393
+ // Exception match: suffix offset uses index+1 (the exception removes its
1394
+ // own left-most label, e.g. !www.ck -> suffix 'ck').
1395
+ it('returns an exception suffix unchanged', () => {
1396
+ expect(tldts.getPublicSuffix('www.ck')).to.equal('ck');
1397
+ });
1398
+ });
1399
+
1400
+ describe('hostname validation dedupe', () => {
1401
+ // Already-valid lowercase hostname: extractHostname returns the SAME string
1402
+ // reference, so the post-extraction validation is skipped — result must
1403
+ // still be that hostname.
1404
+ it('keeps an already-valid hostname', () => {
1405
+ expect(tldts.getHostname('example.co.uk')).to.equal('example.co.uk');
1406
+ expect(tldts.getHostname('sub.example.com')).to.equal('sub.example.com');
1407
+ });
1408
+
1409
+ // Trailing dot is trimmed by extractHostname -> returned string differs from
1410
+ // the input -> the skip guard must NOT fire -> trimmed hostname is validated.
1411
+ it('still validates a trailing-dot hostname (guard does not fire)', () => {
1412
+ expect(tldts.getHostname('miam.miam.google.com.')).to.equal(
1413
+ 'miam.miam.google.com',
1414
+ );
1415
+ });
1416
+
1417
+ // Uppercase input is not a "valid hostname" to the validator, so the full
1418
+ // extraction + lowercasing path runs and the guard does not fire.
1419
+ it('lowercases an uppercase hostname', () => {
1420
+ expect(tldts.getHostname('EXAMPLE.COM')).to.equal('example.com');
1421
+ });
1422
+
1423
+ // An invalid hostname must still be rejected: the guard only skips the
1424
+ // re-scan when the input was ALREADY valid, so it can never keep an invalid one.
1425
+ it('still rejects an invalid hostname', () => {
1426
+ expect(tldts.getHostname('foo-.com')).to.equal(null);
1427
+ });
1428
+ });
1429
+
1430
+ describe('wildcard suffix reconstruction', () => {
1431
+ // *.sch.uk where the matched suffix spans every label of the hostname:
1432
+ // in the packed-hash impl this takes the backward-scan fallback branch
1433
+ // (matchLabels === number of hashed labels). Must return the full suffix.
1434
+ it('returns a wildcard suffix that spans the whole hostname', () => {
1435
+ expect(tldts.getPublicSuffix('foo.sch.uk')).to.equal('foo.sch.uk');
1436
+ });
1437
+
1438
+ // *.sch.uk with an extra leading label (non-fallback wildcard branch).
1439
+ it('returns a wildcard suffix with a leading subdomain present', () => {
1440
+ expect(tldts.getPublicSuffix('pupils.school.sch.uk')).to.equal(
1441
+ 'school.sch.uk',
1442
+ );
1443
+ });
1444
+ });
1445
+
1446
+ describe('prototype-pollution-safe trie traversal', () => {
1447
+ // A label equal to an Object.prototype member, sitting where the trie has a
1448
+ // '*' wildcard child (*.ck), must match the wildcard — NOT resolve to
1449
+ // Object.prototype.constructor / the __proto__ accessor. Guards the
1450
+ // hasOwnProperty check (or, post-Stage-2, the substring-keyed lookup).
1451
+ it('treats constructor/__proto__ labels as ordinary labels under a wildcard', () => {
1452
+ expect(tldts.getPublicSuffix('b.constructor.ck')).to.equal(
1453
+ 'constructor.ck',
1454
+ );
1455
+ expect(tldts.getPublicSuffix('b.__proto__.ck')).to.equal('__proto__.ck');
1456
+ });
1457
+ });
1046
1458
  }