tldts-tests 7.1.0 → 7.1.2

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;
@@ -980,6 +1025,258 @@ export default function test(
980
1025
  }
981
1026
  });
982
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
+
983
1280
  describe('wildcard tests', () => {
984
1281
  parsePublicSuffixRules(loadPublicSuffixList(), (rule) => {
985
1282
  if (