@fedify/webfinger 2.0.35-dev.2480 → 2.0.35-dev.2485

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,9 +1,9 @@
1
1
  import { test } from "@fedify/fixture";
2
- import { FetchError, UrlError, getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime";
2
+ import { FetchError, UrlError, getUserAgent } from "@fedify/vocab-runtime";
3
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
3
4
  import { configure, getLogger, reset } from "@logtape/logtape";
4
5
  import { withTimeout } from "es-toolkit";
5
6
  import { deepStrictEqual, ok } from "node:assert/strict";
6
- import dns from "node:dns/promises";
7
7
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
8
8
  //#region \0rolldown/runtime.js
9
9
  var __create = Object.create;
@@ -1228,7 +1228,7 @@ var esm_default = new class FetchMock {
1228
1228
  //#endregion
1229
1229
  //#region deno.json
1230
1230
  var name = "@fedify/webfinger";
1231
- var version = "2.0.35-dev.2480+2330a2e7";
1231
+ var version = "2.0.35-dev.2485+75206550";
1232
1232
  //#endregion
1233
1233
  //#region src/body.ts
1234
1234
  /** Decoded JSON body limit: 16 MiB. @internal */
@@ -1304,7 +1304,7 @@ async function readBoundedText(message, maxBytes, url) {
1304
1304
  }
1305
1305
  }
1306
1306
  //#endregion
1307
- //#region src/lookup.ts
1307
+ //#region src/lookup-impl.ts
1308
1308
  const logger = getLogger([
1309
1309
  "fedify",
1310
1310
  "webfinger",
@@ -1312,36 +1312,16 @@ const logger = getLogger([
1312
1312
  ]);
1313
1313
  const DEFAULT_MAX_REDIRECTION = 5;
1314
1314
  /**
1315
- * Looks up a WebFinger resource.
1315
+ * Looks up a WebFinger resource, opening a fetch session per hop. This is
1316
+ * the implementation of {@link lookupWebFinger}, which is not exported from
1317
+ * the package entry point.
1316
1318
  * @param resource The resource URL to look up.
1317
1319
  * @param options Extra options for looking up the resource.
1320
+ * @param deps The dependencies.
1318
1321
  * @returns The resource descriptor, or `null` if not found.
1319
- * @since 0.2.0
1322
+ * @internal
1320
1323
  */
1321
- async function lookupWebFinger(resource, options = {}) {
1322
- return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("webfinger.lookup", {
1323
- kind: SpanKind.CLIENT,
1324
- attributes: {
1325
- "webfinger.resource": resource.toString(),
1326
- "webfinger.resource.scheme": typeof resource === "string" ? resource.replace(/:.*$/, "") : resource.protocol.replace(/:$/, "")
1327
- }
1328
- }, async (span) => {
1329
- try {
1330
- const result = await lookupWebFingerInternal(resource, options);
1331
- span.setStatus({ code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1332
- return result;
1333
- } catch (error) {
1334
- span.setStatus({
1335
- code: SpanStatusCode.ERROR,
1336
- message: String(error)
1337
- });
1338
- throw error;
1339
- } finally {
1340
- span.end();
1341
- }
1342
- });
1343
- }
1344
- async function lookupWebFingerInternal(resource, options = {}) {
1324
+ async function lookupWebFingerInternal(resource, options = {}, deps = {}) {
1345
1325
  if (typeof resource === "string") resource = new URL(resource);
1346
1326
  let protocol = "https:";
1347
1327
  let server;
@@ -1356,79 +1336,169 @@ async function lookupWebFingerInternal(resource, options = {}) {
1356
1336
  }
1357
1337
  let url = new URL(`${protocol}//${server}/.well-known/webfinger`);
1358
1338
  url.searchParams.set("resource", resource.href);
1339
+ const openHop = deps.openHop ?? ((hopUrl) => options.allowPrivateAddress === true ? openDirectFetch(hopUrl) : openPublicFetch(hopUrl, { signal: options.signal }));
1359
1340
  let redirected = 0;
1360
1341
  while (true) {
1361
1342
  logger.debug("Fetching WebFinger resource descriptor from {url}...", { url: url.href });
1362
- let response;
1363
- if (options.allowPrivateAddress !== true) try {
1364
- await validatePublicUrl(url.href);
1365
- } catch (e) {
1366
- if (e instanceof UrlError) {
1367
- if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1368
- url: url.href,
1369
- error: e
1370
- });
1371
- else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1372
- return null;
1373
- }
1374
- throw e;
1375
- }
1343
+ const session = await openSession(url, openHop, options.signal);
1344
+ if (session == null) return null;
1345
+ let hop;
1376
1346
  try {
1377
- response = await fetch(url, {
1378
- headers: {
1379
- Accept: "application/jrd+json",
1380
- "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
1381
- },
1382
- redirect: "manual",
1383
- signal: options.signal
1347
+ hop = await fetchHop(session, url, options);
1348
+ } finally {
1349
+ await session.close();
1350
+ }
1351
+ if ("result" in hop) return hop.result;
1352
+ redirected++;
1353
+ const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
1354
+ if (redirected >= maxRedirection) {
1355
+ logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
1356
+ return null;
1357
+ }
1358
+ const redirectedUrl = new URL(hop.redirect, url);
1359
+ if (redirectedUrl.protocol !== url.protocol) {
1360
+ logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
1361
+ protocol: url.protocol,
1362
+ redirectedProtocol: redirectedUrl.protocol
1384
1363
  });
1385
- } catch (error) {
1386
- logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1364
+ return null;
1365
+ }
1366
+ url = redirectedUrl;
1367
+ }
1368
+ }
1369
+ /**
1370
+ * Opens a fetch session for a hop. A disallowed URL, a failed DNS lookup,
1371
+ * or an aborted lookup yields `null` instead of an error.
1372
+ */
1373
+ async function openSession(url, openHop, signal) {
1374
+ try {
1375
+ return await openHop(url.href);
1376
+ } catch (e) {
1377
+ if (e instanceof UrlError) {
1378
+ if (e.reason === "dns") logger.debug("DNS lookup failed for {url}", {
1387
1379
  url: url.href,
1388
- error
1380
+ error: e
1389
1381
  });
1382
+ else logger.error("Invalid URL for WebFinger resource descriptor: {error}", { error: e });
1390
1383
  return null;
1391
1384
  }
1392
- if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
1393
- redirected++;
1394
- const maxRedirection = options.maxRedirection ?? DEFAULT_MAX_REDIRECTION;
1395
- if (redirected >= maxRedirection) {
1396
- logger.error("Too many redirections ({redirections}) while fetching WebFinger resource descriptor.", { redirections: redirected });
1397
- return null;
1398
- }
1399
- const redirectedUrl = new URL(response.headers.get("Location"), response.url == null || response.url === "" ? url : response.url);
1400
- if (redirectedUrl.protocol !== url.protocol) {
1401
- logger.error("Redirected to a different protocol ({protocol} to {redirectedProtocol}) while fetching WebFinger resource descriptor.", {
1402
- protocol: url.protocol,
1403
- redirectedProtocol: redirectedUrl.protocol
1404
- });
1405
- return null;
1406
- }
1407
- url = redirectedUrl;
1408
- continue;
1409
- }
1410
- if (!response.ok) {
1411
- logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
1385
+ if (signal?.aborted) {
1386
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1412
1387
  url: url.href,
1413
- status: response.status,
1414
- statusText: response.statusText
1388
+ error: e
1415
1389
  });
1416
1390
  return null;
1417
1391
  }
1418
- try {
1419
- return JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url));
1420
- } catch (e) {
1421
- if (e instanceof BodyTooLargeError) return null;
1422
- if (e instanceof SyntaxError) {
1423
- logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1424
- return null;
1425
- }
1426
- throw e;
1392
+ throw e;
1393
+ }
1394
+ }
1395
+ /**
1396
+ * Fetches a hop with the given session, and consumes the response body or
1397
+ * starts cancelling it before returning. The caller closes the session.
1398
+ */
1399
+ async function fetchHop(session, url, options) {
1400
+ let response;
1401
+ try {
1402
+ response = await session.fetch(url, {
1403
+ headers: {
1404
+ Accept: "application/jrd+json",
1405
+ "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent)
1406
+ },
1407
+ redirect: "manual",
1408
+ signal: options.signal
1409
+ });
1410
+ } catch (error) {
1411
+ logger.debug("Failed to fetch WebFinger resource descriptor: {error}", {
1412
+ url: url.href,
1413
+ error
1414
+ });
1415
+ return { result: null };
1416
+ }
1417
+ if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
1418
+ cancelBody(response, url);
1419
+ return { redirect: response.headers.get("Location") };
1420
+ }
1421
+ if (!response.ok) {
1422
+ logger.debug("Failed to fetch WebFinger resource descriptor: {status} {statusText}.", {
1423
+ url: url.href,
1424
+ status: response.status,
1425
+ statusText: response.statusText
1426
+ });
1427
+ cancelBody(response, url);
1428
+ return { result: null };
1429
+ }
1430
+ try {
1431
+ return { result: JSON.parse(await readBoundedText(response, MAX_BODY_SIZE, url)) };
1432
+ } catch (e) {
1433
+ if (e instanceof BodyTooLargeError) return { result: null };
1434
+ if (e instanceof SyntaxError) {
1435
+ logger.debug("Failed to parse WebFinger resource descriptor as JSON: {error}", { error: e });
1436
+ return { result: null };
1427
1437
  }
1438
+ throw e;
1428
1439
  }
1429
1440
  }
1441
+ /**
1442
+ * Starts cancelling a response body that is not read, without waiting for it
1443
+ * to settle. The cancellation of a branch of a tee (e.g., a body cloned by
1444
+ * a fetch mock) settles only after the other branch is cancelled too, per
1445
+ * the Streams standard, so awaiting it could block forever. A failure is
1446
+ * only logged.
1447
+ */
1448
+ function cancelBody(response, url) {
1449
+ response.body?.cancel().catch((error) => {
1450
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
1451
+ url: url.href,
1452
+ error
1453
+ });
1454
+ });
1455
+ }
1456
+ //#endregion
1457
+ //#region src/lookup.ts
1458
+ /**
1459
+ * Looks up a WebFinger resource.
1460
+ * @param resource The resource URL to look up.
1461
+ * @param options Extra options for looking up the resource.
1462
+ * @returns The resource descriptor, or `null` if not found.
1463
+ * @since 0.2.0
1464
+ */
1465
+ async function lookupWebFinger(resource, options = {}) {
1466
+ return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("webfinger.lookup", {
1467
+ kind: SpanKind.CLIENT,
1468
+ attributes: {
1469
+ "webfinger.resource": resource.toString(),
1470
+ "webfinger.resource.scheme": typeof resource === "string" ? resource.replace(/:.*$/, "") : resource.protocol.replace(/:$/, "")
1471
+ }
1472
+ }, async (span) => {
1473
+ try {
1474
+ const result = await lookupWebFingerInternal(resource, options);
1475
+ span.setStatus({ code: result === null ? SpanStatusCode.ERROR : SpanStatusCode.OK });
1476
+ return result;
1477
+ } catch (error) {
1478
+ span.setStatus({
1479
+ code: SpanStatusCode.ERROR,
1480
+ message: String(error)
1481
+ });
1482
+ throw error;
1483
+ } finally {
1484
+ span.end();
1485
+ }
1486
+ });
1487
+ }
1430
1488
  //#endregion
1431
1489
  //#region src/lookup.test.ts
1490
+ function lookupDirect(resource, options) {
1491
+ return lookupWebFingerInternal(resource, options, { openHop: openDirectFetch });
1492
+ }
1493
+ function lookupResolving(addresses) {
1494
+ return () => Promise.resolve(addresses);
1495
+ }
1496
+ function openExampleDirectly(url) {
1497
+ return new URL(url).hostname === "example.com" ? openDirectFetch(url) : openPublicFetch(url, { lookup: lookupResolving([{
1498
+ address: "93.184.215.14",
1499
+ family: 4
1500
+ }]) });
1501
+ }
1432
1502
  test({
1433
1503
  name: "lookupWebFinger()",
1434
1504
  sanitizeOps: false,
@@ -1444,11 +1514,31 @@ test({
1444
1514
  deepStrictEqual(await lookupWebFinger("acct:johndoe@fedify-test.internal"), null);
1445
1515
  deepStrictEqual(await lookupWebFinger("https://fedify-test.internal/foo"), null);
1446
1516
  });
1517
+ await t.step("fedify-test.internal resolving to a private address", async () => {
1518
+ esm_default.mockGlobal();
1519
+ esm_default.catch(500);
1520
+ try {
1521
+ const hosts = [];
1522
+ const openHop = (url) => openPublicFetch(url, { lookup: (hostname) => {
1523
+ hosts.push(hostname);
1524
+ return Promise.resolve([{
1525
+ address: "10.0.0.1",
1526
+ family: 4
1527
+ }]);
1528
+ } });
1529
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@fedify-test.internal", {}, { openHop }), null);
1530
+ deepStrictEqual(await lookupWebFingerInternal("https://fedify-test.internal/foo", {}, { openHop }), null);
1531
+ deepStrictEqual(hosts, ["fedify-test.internal", "fedify-test.internal"]);
1532
+ deepStrictEqual(esm_default.callHistory.calls().length, 0);
1533
+ } finally {
1534
+ esm_default.hardReset();
1535
+ }
1536
+ });
1447
1537
  esm_default.spyGlobal();
1448
1538
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { status: 404 });
1449
1539
  await t.step("not found", async () => {
1450
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1451
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), null);
1540
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
1541
+ deepStrictEqual(await lookupDirect("https://example.com/foo"), null);
1452
1542
  });
1453
1543
  const expected = {
1454
1544
  subject: "acct:johndoe@example.com",
@@ -1457,7 +1547,7 @@ test({
1457
1547
  esm_default.removeRoutes();
1458
1548
  esm_default.get("https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com", { body: expected });
1459
1549
  await t.step("acct", async () => {
1460
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1550
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
1461
1551
  });
1462
1552
  const expected2 = {
1463
1553
  subject: "https://example.com/foo",
@@ -1466,12 +1556,12 @@ test({
1466
1556
  esm_default.removeRoutes();
1467
1557
  esm_default.get("https://example.com/.well-known/webfinger?resource=https%3A%2F%2Fexample.com%2Ffoo", { body: expected2 });
1468
1558
  await t.step("https", async () => {
1469
- deepStrictEqual(await lookupWebFinger("https://example.com/foo"), expected2);
1559
+ deepStrictEqual(await lookupDirect("https://example.com/foo"), expected2);
1470
1560
  });
1471
1561
  esm_default.removeRoutes();
1472
1562
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: "not json" });
1473
1563
  await t.step("invalid response", async () => {
1474
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1564
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
1475
1565
  });
1476
1566
  esm_default.removeRoutes();
1477
1567
  esm_default.get("begin:https://localhost/.well-known/webfinger?", {
@@ -1500,7 +1590,7 @@ test({
1500
1590
  });
1501
1591
  esm_default.get("begin:https://example.com/.well-known/webfinger2", { body: expected });
1502
1592
  await t.step("redirection", async () => {
1503
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1593
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
1504
1594
  });
1505
1595
  esm_default.removeRoutes();
1506
1596
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1508,7 +1598,7 @@ test({
1508
1598
  headers: { Location: "/.well-known/webfinger" }
1509
1599
  });
1510
1600
  await t.step("infinite redirection", async () => {
1511
- deepStrictEqual(await withTimeout(() => lookupWebFinger("acct:johndoe@example.com"), 2e3), null);
1601
+ deepStrictEqual(await withTimeout(() => lookupDirect("acct:johndoe@example.com"), 2e3), null);
1512
1602
  });
1513
1603
  esm_default.removeRoutes();
1514
1604
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1516,7 +1606,7 @@ test({
1516
1606
  headers: { Location: "ftp://example.com/" }
1517
1607
  });
1518
1608
  await t.step("redirection to different protocol", async () => {
1519
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1609
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), null);
1520
1610
  });
1521
1611
  esm_default.removeRoutes();
1522
1612
  esm_default.get("begin:https://example.com/.well-known/webfinger?", {
@@ -1524,7 +1614,7 @@ test({
1524
1614
  headers: { Location: "https://localhost/" }
1525
1615
  });
1526
1616
  await t.step("redirection to private address", async () => {
1527
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), null);
1617
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: openExampleDirectly }), null);
1528
1618
  });
1529
1619
  esm_default.removeRoutes();
1530
1620
  let redirectCount = 0;
@@ -1538,11 +1628,11 @@ test({
1538
1628
  });
1539
1629
  await t.step("custom maxRedirection", async () => {
1540
1630
  redirectCount = 0;
1541
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 2 }), null);
1631
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { maxRedirection: 2 }), null);
1542
1632
  redirectCount = 0;
1543
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1633
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { maxRedirection: 3 }), expected);
1544
1634
  redirectCount = 0;
1545
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com"), expected);
1635
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com"), expected);
1546
1636
  });
1547
1637
  esm_default.removeRoutes();
1548
1638
  esm_default.get("begin:https://example.com/.well-known/webfinger?", () => new Promise((resolve) => {
@@ -1553,7 +1643,7 @@ test({
1553
1643
  }));
1554
1644
  await t.step("request cancellation", async () => {
1555
1645
  const controller = new AbortController();
1556
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1646
+ const promise = lookupDirect("acct:johndoe@example.com", { signal: controller.signal });
1557
1647
  controller.abort();
1558
1648
  deepStrictEqual(await promise, null);
1559
1649
  });
@@ -1574,7 +1664,7 @@ test({
1574
1664
  });
1575
1665
  await t.step("cancellation during redirection", async () => {
1576
1666
  const controller = new AbortController();
1577
- const promise = lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal });
1667
+ const promise = lookupDirect("acct:johndoe@example.com", { signal: controller.signal });
1578
1668
  setTimeout(() => controller.abort(), 100);
1579
1669
  deepStrictEqual(await promise, null);
1580
1670
  });
@@ -1588,12 +1678,13 @@ test({
1588
1678
  await t.step("cancellation with immediate abort", async () => {
1589
1679
  const controller = new AbortController();
1590
1680
  controller.abort();
1681
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { signal: controller.signal }), null);
1591
1682
  deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: controller.signal }), null);
1592
1683
  });
1593
1684
  esm_default.removeRoutes();
1594
1685
  esm_default.get("begin:https://example.com/.well-known/webfinger?", { body: expected });
1595
1686
  await t.step("successful request with signal", async () => {
1596
- deepStrictEqual(await lookupWebFinger("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1687
+ deepStrictEqual(await lookupDirect("acct:johndoe@example.com", { signal: new AbortController().signal }), expected);
1597
1688
  });
1598
1689
  esm_default.hardReset();
1599
1690
  }
@@ -1614,17 +1705,15 @@ test("lookupWebFinger() bounds resource descriptors", async () => {
1614
1705
  }
1615
1706
  });
1616
1707
  test("lookupWebFinger() logs DNS failures as such", async (t) => {
1617
- if ("Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted") return;
1618
1708
  for (const result of [
1619
1709
  "throws",
1620
1710
  "empty",
1621
1711
  "private"
1622
1712
  ]) await t.step(result, async () => {
1623
- const originalLookup = dns.lookup;
1624
- dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
1713
+ const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
1625
1714
  address: "127.0.0.1",
1626
1715
  family: 4
1627
- }]));
1716
+ }]);
1628
1717
  const records = [];
1629
1718
  await configure({
1630
1719
  sinks: { buffer: (record) => records.push(record) },
@@ -1639,7 +1728,7 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
1639
1728
  reset: true
1640
1729
  });
1641
1730
  try {
1642
- deepStrictEqual(await lookupWebFinger("acct:alice@dns-failure.invalid"), null);
1731
+ deepStrictEqual(await lookupWebFingerInternal("acct:alice@dns-failure.invalid", {}, { openHop: (url) => openPublicFetch(url, { lookup }) }), null);
1643
1732
  const failures = records.filter((r) => r.rawMessage !== "Fetching WebFinger resource descriptor from {url}...");
1644
1733
  deepStrictEqual(failures.map((r) => [r.level, r.rawMessage]), [result === "private" ? ["error", "Invalid URL for WebFinger resource descriptor: {error}"] : ["debug", "DNS lookup failed for {url}"]]);
1645
1734
  const { error } = failures[0].properties;
@@ -1647,9 +1736,249 @@ test("lookupWebFinger() logs DNS failures as such", async (t) => {
1647
1736
  deepStrictEqual(error.reason, result === "private" ? "disallowed" : "dns");
1648
1737
  } finally {
1649
1738
  await reset();
1650
- dns.lookup = originalLookup;
1651
1739
  }
1652
1740
  });
1653
1741
  });
1742
+ function recordHops(respond) {
1743
+ const events = [];
1744
+ const unsettled = [];
1745
+ let opens = 0;
1746
+ let closes = 0;
1747
+ let responses = 0;
1748
+ return {
1749
+ events,
1750
+ async openHop(url) {
1751
+ opens++;
1752
+ events.push(`open ${url}`);
1753
+ const states = [];
1754
+ let closed = false;
1755
+ return {
1756
+ url: new URL(url),
1757
+ async fetch(input) {
1758
+ if (closed) throw new TypeError("The fetch session is closed.");
1759
+ const target = new URL(input instanceof Request ? input.url : input);
1760
+ const id = ++responses;
1761
+ events.push(`fetch ${target.href} #${id}`);
1762
+ const fake = respond(target);
1763
+ const state = {
1764
+ id,
1765
+ settled: false
1766
+ };
1767
+ states.push(state);
1768
+ let sent = false;
1769
+ const body = new ReadableStream({
1770
+ pull(controller) {
1771
+ if (!sent && fake.body != null && fake.body !== "") {
1772
+ sent = true;
1773
+ controller.enqueue(new TextEncoder().encode(fake.body));
1774
+ return;
1775
+ }
1776
+ state.settled = true;
1777
+ events.push(`consumed #${id}`);
1778
+ controller.close();
1779
+ },
1780
+ cancel() {
1781
+ state.settled = true;
1782
+ events.push(`cancel #${id}`);
1783
+ if (fake.cancelFails) throw new Error("Cancellation failed");
1784
+ if (fake.cancelNeverSettles) return new Promise(() => {});
1785
+ }
1786
+ }, { highWaterMark: 0 });
1787
+ const response = new Response(body, {
1788
+ status: fake.status ?? 200,
1789
+ headers: fake.headers
1790
+ });
1791
+ if (fake.url != null) Object.defineProperty(response, "url", { value: fake.url });
1792
+ return response;
1793
+ },
1794
+ async close() {
1795
+ if (closed) return;
1796
+ closed = true;
1797
+ closes++;
1798
+ for (const state of states) if (!state.settled) unsettled.push(`#${state.id}`);
1799
+ events.push(`close ${url}`);
1800
+ }
1801
+ };
1802
+ },
1803
+ assertInvariants() {
1804
+ deepStrictEqual(closes, opens);
1805
+ deepStrictEqual(unsettled, []);
1806
+ }
1807
+ };
1808
+ }
1809
+ test("lookupWebFinger() closes each hop after cleaning up its body", async (t) => {
1810
+ const descriptor = {
1811
+ subject: "acct:johndoe@example.com",
1812
+ links: []
1813
+ };
1814
+ const first = "https://example.com/.well-known/webfinger?resource=acct%3Ajohndoe%40example.com";
1815
+ const second = "https://example.com/.well-known/webfinger2";
1816
+ await t.step("redirect", async () => {
1817
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
1818
+ status: 302,
1819
+ headers: { Location: "/.well-known/webfinger2" },
1820
+ body: "redirecting",
1821
+ url: url.href.replace("example.com", "93.184.215.14")
1822
+ } : { body: JSON.stringify(descriptor) });
1823
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
1824
+ deepStrictEqual(recorder.events, [
1825
+ `open ${first}`,
1826
+ `fetch ${first} #1`,
1827
+ "cancel #1",
1828
+ `close ${first}`,
1829
+ `open ${second}`,
1830
+ `fetch ${second} #2`,
1831
+ "consumed #2",
1832
+ `close ${second}`
1833
+ ]);
1834
+ recorder.assertInvariants();
1835
+ });
1836
+ await t.step("non-2xx response", async () => {
1837
+ const recorder = recordHops(() => ({
1838
+ status: 404,
1839
+ body: "not found"
1840
+ }));
1841
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), null);
1842
+ deepStrictEqual(recorder.events, [
1843
+ `open ${first}`,
1844
+ `fetch ${first} #1`,
1845
+ "cancel #1",
1846
+ `close ${first}`
1847
+ ]);
1848
+ recorder.assertInvariants();
1849
+ });
1850
+ await t.step("too many redirections", async () => {
1851
+ const recorder = recordHops(() => ({
1852
+ status: 302,
1853
+ headers: { Location: "/.well-known/webfinger2" },
1854
+ body: "redirecting"
1855
+ }));
1856
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", { maxRedirection: 2 }, { openHop: recorder.openHop }), null);
1857
+ deepStrictEqual(recorder.events, [
1858
+ `open ${first}`,
1859
+ `fetch ${first} #1`,
1860
+ "cancel #1",
1861
+ `close ${first}`,
1862
+ `open ${second}`,
1863
+ `fetch ${second} #2`,
1864
+ "cancel #2",
1865
+ `close ${second}`
1866
+ ]);
1867
+ recorder.assertInvariants();
1868
+ });
1869
+ await t.step("failed redirect body cancellation", async () => {
1870
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
1871
+ status: 302,
1872
+ headers: { Location: "/.well-known/webfinger2" },
1873
+ body: "redirecting",
1874
+ cancelFails: true
1875
+ } : { body: JSON.stringify(descriptor) });
1876
+ const records = [];
1877
+ await configure({
1878
+ sinks: { buffer: (record) => records.push(record) },
1879
+ loggers: [{
1880
+ category: "fedify",
1881
+ sinks: ["buffer"],
1882
+ lowestLevel: "debug"
1883
+ }, {
1884
+ category: ["logtape", "meta"],
1885
+ sinks: []
1886
+ }],
1887
+ reset: true
1888
+ });
1889
+ try {
1890
+ deepStrictEqual(await lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop }), descriptor);
1891
+ const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
1892
+ deepStrictEqual(failures.map((r) => [r.level, r.properties.url]), [["debug", first]]);
1893
+ const { error } = failures[0].properties;
1894
+ ok(error instanceof Error);
1895
+ deepStrictEqual(error.message, "Cancellation failed");
1896
+ } finally {
1897
+ await reset();
1898
+ }
1899
+ deepStrictEqual(recorder.events, [
1900
+ `open ${first}`,
1901
+ `fetch ${first} #1`,
1902
+ "cancel #1",
1903
+ `close ${first}`,
1904
+ `open ${second}`,
1905
+ `fetch ${second} #2`,
1906
+ "consumed #2",
1907
+ `close ${second}`
1908
+ ]);
1909
+ recorder.assertInvariants();
1910
+ });
1911
+ await t.step("never-settling body cancellation", async () => {
1912
+ const recorder = recordHops((url) => url.pathname === "/.well-known/webfinger" ? {
1913
+ status: 302,
1914
+ headers: { Location: "/.well-known/webfinger2" },
1915
+ body: "redirecting",
1916
+ cancelNeverSettles: true
1917
+ } : {
1918
+ status: 404,
1919
+ body: "not found",
1920
+ cancelNeverSettles: true
1921
+ });
1922
+ deepStrictEqual(await settleWithin(lookupWebFingerInternal("acct:johndoe@example.com", {}, { openHop: recorder.openHop })), null);
1923
+ deepStrictEqual(recorder.events, [
1924
+ `open ${first}`,
1925
+ `fetch ${first} #1`,
1926
+ "cancel #1",
1927
+ `close ${first}`,
1928
+ `open ${second}`,
1929
+ `fetch ${second} #2`,
1930
+ "cancel #2",
1931
+ `close ${second}`
1932
+ ]);
1933
+ recorder.assertInvariants();
1934
+ });
1935
+ });
1936
+ /**
1937
+ * Rejects if the given promise does not settle within the timeout, so that
1938
+ * a hang fails the test instead of blocking it.
1939
+ */
1940
+ async function settleWithin(promise, ms = 5e3) {
1941
+ let timer;
1942
+ try {
1943
+ return await Promise.race([promise, new Promise((_, reject) => {
1944
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`Did not settle within ${ms} ms.`)), ms);
1945
+ })]);
1946
+ } finally {
1947
+ clearTimeout(timer);
1948
+ }
1949
+ }
1950
+ function neverCancellableResponse(init) {
1951
+ return new Response(new ReadableStream({
1952
+ start(controller) {
1953
+ controller.enqueue(new TextEncoder().encode("never cancelled"));
1954
+ },
1955
+ cancel: () => new Promise(() => {})
1956
+ }), init);
1957
+ }
1958
+ test("lookupWebFinger() does not wait for cancelling response bodies", async (t) => {
1959
+ const descriptor = {
1960
+ subject: "acct:johndoe@example.com",
1961
+ links: []
1962
+ };
1963
+ esm_default.mockGlobal();
1964
+ try {
1965
+ await t.step("redirect", async () => {
1966
+ esm_default.removeRoutes();
1967
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({
1968
+ status: 302,
1969
+ headers: { Location: "/.well-known/webfinger2" }
1970
+ }));
1971
+ esm_default.get("https://example.com/.well-known/webfinger2", () => new Response(JSON.stringify(descriptor), { headers: { "Content-Type": "application/jrd+json" } }));
1972
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), descriptor);
1973
+ });
1974
+ await t.step("non-2xx response", async () => {
1975
+ esm_default.removeRoutes();
1976
+ esm_default.get("begin:https://example.com/.well-known/webfinger?", () => neverCancellableResponse({ status: 404 }));
1977
+ deepStrictEqual(await settleWithin(lookupDirect("acct:johndoe@example.com")), null);
1978
+ });
1979
+ } finally {
1980
+ esm_default.hardReset();
1981
+ }
1982
+ });
1654
1983
  //#endregion
1655
1984
  export {};