/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// Verifies the Happy Eyeballs HTTP/1 0-RTT-accepted code path:
//  * The winning HT is promoted (conn.mTransaction becomes a pass-
//    through to the real nsHttpTransaction) so no re-Activate /
//    DispatchTransaction races with the MOZ_ASSERT in the conn mgr.
//  * The real transaction does NOT re-send its request on the wire
//    after the server has accepted the early-data bytes.
//
// We run against ZeroRttAcceptServer — a TLS 1.3 test server that
// accepts 0-RTT, reads HTTP requests in full, and fires a callback on
// every request it observes on the wire. The test counts callbacks:
// one per user-visible fetch. A duplicate request post-handshake
// (i.e. the bug this fix guards against) would produce a second
// callback on the same connection.

"use strict";

const { HttpServer } = ChromeUtils.importESModule(
  "resource://testing-common/httpd.sys.mjs"
);
var { setTimeout } = ChromeUtils.importESModule(
  "resource://gre/modules/Timer.sys.mjs"
);
const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);
const { NodeHTTPServer } = ChromeUtils.importESModule(
  "resource://testing-common/NodeServer.sys.mjs"
);

let callbackServer;
// Server splits /callback/request/{early,std} by whether the request
// arrived via accepted 0-RTT early data. Track the two counters
// separately so tests can assert which path delivered each fetch.
let earlyCount = 0;
let stdCount = 0;

function callbackHandler(metadata) {
  if (metadata.path === "/callback/request/early") {
    earlyCount++;
  } else if (metadata.path === "/callback/request/std") {
    stdCount++;
  }
}

function resetCounts() {
  earlyCount = 0;
  stdCount = 0;
}

add_setup(
  {
    skip_if: () => AppConstants.MOZ_SYSTEM_NSS,
  },
  async () => {
    callbackServer = new HttpServer();
    callbackServer.registerPrefixHandler("/callback/", callbackHandler);
    callbackServer.start(-1);

    Services.env.set(
      "MOZ_ZERORTT_ACCEPT_CALLBACK_PORT",
      callbackServer.identity.primaryPort
    );
    Services.env.set("MOZ_TLS_SERVER_0RTT", "1");
    await asyncStartTLSTestServer(
      "ZeroRttAcceptServer",
      "../../../security/manager/ssl/tests/unit/test_faulty_server"
    );
    let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
    await nssComponent.asyncClearSSLExternalAndInternalSessionCache();

    Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true);
    Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
    Services.prefs.setBoolPref("network.ssl_tokens_cache_enabled", true);
    Services.prefs.setBoolPref("network.http.http3.enable", false);
    // ZeroRttAcceptServer binds IPv4 loopback only. HE otherwise tries
    // [::1] first and the losing IPv6 attempt perturbs 0-RTT state for
    // the winning IPv4 attempt, collapsing fetch#2 into the std path.
    // Race tasks supply both families via a reverse proxy and flip
    // this pref themselves.
    Services.prefs.setBoolPref("network.dns.disableIPv6", true);

    registerCleanupFunction(async () => {
      Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled");
      Services.prefs.clearUserPref("network.http.speculative-parallel-limit");
      Services.prefs.clearUserPref("network.ssl_tokens_cache_enabled");
      Services.prefs.clearUserPref("network.http.http3.enable");
      Services.prefs.clearUserPref("network.dns.disableIPv6");
      if (callbackServer) {
        await callbackServer.stop();
      }
    });
  }
);

function fetchExpect200(url) {
  return new Promise(resolve => {
    let chan = NetUtil.newChannel({
      uri: url,
      loadUsingSystemPrincipal: true,
    }).QueryInterface(Ci.nsIHttpChannel);
    chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI;
    chan.asyncOpen(
      new ChannelListener(
        req => {
          let httpChan = req.QueryInterface(Ci.nsIHttpChannel);
          let httpChanInt = req.QueryInterface(Ci.nsIHttpChannelInternal);
          let status = 0;
          let remote = "";
          let resumed = false;
          try {
            status = httpChan.responseStatus;
          } catch (e) {}
          try {
            remote = httpChanInt.remoteAddress;
          } catch (e) {}
          try {
            resumed = req.securityInfo.resumed;
          } catch (e) {}
          resolve({ status, remote, resumed });
        },
        null,
        CL_ALLOW_UNKNOWN_CL
      )
    );
  });
}

async function runHandshakeThenResume(host) {
  Services.prefs.setCharPref("network.dns.localDomains", host);
  const url = `https://${host}:8443/`;

  resetCounts();

  let r1 = await fetchExpect200(url);
  Assert.equal(r1.status, 200, "First fetch should succeed");

  // Anti-replay window + NewSessionTicket propagation.
  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  await new Promise(resolve => setTimeout(resolve, 1500));

  Services.obs.notifyObservers(null, "net:cancel-all-connections");
  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  await new Promise(resolve => setTimeout(resolve, 200));

  let r2 = await fetchExpect200(url);
  Assert.equal(r2.status, 200, "Second fetch should succeed");

  // Drop the keep-alive conn from fetch #2 so the server's single-
  // threaded accept loop (parked in PR_Recv inside HandleH1Session)
  // gets EOF and returns, freeing it for the next task's ClientHello.
  Services.obs.notifyObservers(null, "net:cancel-all-connections");
  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  await new Promise(resolve => setTimeout(resolve, 200));
}

add_task(
  {
    skip_if: () => AppConstants.MOZ_SYSTEM_NSS,
  },
  async function test_he_h1_0rtt_accepted_no_duplicate_on_the_wire() {
    // Server accepts 0-RTT. HE promotes the winning HT to forward to
    // the real txn. Real txn's request stream is positioned at the
    // winner's offset so ReadSegments returns 0 bytes and
    // nsHttpConnection treats the request as fully sent. No duplicate
    // request burst is produced on the wire.
    await runHandshakeThenResume("0rtt-accept-h1.example.com");
    Assert.equal(stdCount, 1, "Fetch #1 arrived on the standard path");
    Assert.equal(earlyCount, 1, "Fetch #2 arrived as accepted 0-RTT");
  }
);

add_task(
  {
    skip_if: () => AppConstants.MOZ_SYSTEM_NSS,
  },
  async function test_he_h1_0rtt_rejected_restarts_cleanly() {
    // Server refuses 0-RTT on resumption (no anti-replay context), so
    // NSS reports "early data not accepted" and ZeroRttHandle drives
    // Finish0RTT(aRestart=true): the request stream seeks back to 0
    // and the real txn retransmits over the post-handshake wire. The
    // server sees both fetches on the standard path (the early-data
    // bytes are discarded by NSS before our handler ever sees them).
    await runHandshakeThenResume("0rtt-reject-h1.example.com");
    Assert.equal(earlyCount, 0, "No request should arrive as 0-RTT");
    Assert.equal(stdCount, 2, "Both fetches arrived on the standard path");
  }
);

// Reverse TCP proxy that listens on ::1 and 127.0.0.1 at the same
// port and forwards raw bytes to the TLS server on 127.0.0.1:8443.
// Each family can be artificially delayed so HE picks a specific
// winner in the 0-RTT resumption race. TLS is not terminated — the
// ClientHello's SNI and early data pass through intact, so the
// backend still decides ALPN / 0-RTT accept per our sHosts entries.
async function startFamilyDelayProxy(node, ipv6DelayMs, ipv4DelayMs) {
  return node.execute(`
    (function() {
      const net = require("net");
      function pipeToBackend(clientSocket, delay) {
        let buffered = [];
        let clientDead = false;
        clientSocket.on("data", (chunk) => buffered.push(chunk));
        clientSocket.on("error", () => { clientDead = true; });
        // Track client-side teardown up front: the HE race deliberately
        // cancels the losing attempt mid-delay, so without these early
        // listeners the deferred net.connect below would forward a
        // stale ClientHello to the single-threaded backend and park its
        // accept loop on a dead conn.
        clientSocket.on("end", () => { clientDead = true; });
        clientSocket.on("close", () => { clientDead = true; });

        setTimeout(() => {
          if (clientDead) {
            try { clientSocket.destroy(); } catch(e) {}
            return;
          }
          const backendSocket = net.connect(8443, "127.0.0.1", () => {
            for (const chunk of buffered) {
              backendSocket.write(chunk);
            }
            buffered = null;
            clientSocket.removeAllListeners("data");
            clientSocket.on("data", (chunk) => backendSocket.write(chunk));
            backendSocket.on("data", (chunk) => {
              try { clientSocket.write(chunk); } catch(e) {}
            });
            backendSocket.on("end", () => {
              try { clientSocket.end(); } catch(e) {}
            });
            clientSocket.on("end", () => backendSocket.end());
            backendSocket.on("error", () => clientSocket.destroy());
            clientSocket.on("error", () => backendSocket.destroy());
          });
          backendSocket.on("error", () => clientSocket.destroy());
        }, delay);
      }
      function makeProxy(delay) {
        return net.createServer((clientSocket) => {
          pipeToBackend(clientSocket, delay);
        });
      }
      const proxy6 = makeProxy(${ipv6DelayMs});
      const proxy4 = makeProxy(${ipv4DelayMs});
      return new Promise((resolve, reject) => {
        proxy6.once("error", reject);
        proxy6.listen(0, "::1", () => {
          const port = proxy6.address().port;
          proxy4.once("error", reject);
          proxy4.listen(port, "127.0.0.1", () => {
            global.delayProxy6 = proxy6;
            global.delayProxy4 = proxy4;
            resolve(port);
          });
        });
      });
    })()
  `);
}

async function stopFamilyDelayProxy(node) {
  await node.execute(`
    if (global.delayProxy6) { global.delayProxy6.close(); global.delayProxy6 = null; }
    if (global.delayProxy4) { global.delayProxy4.close(); global.delayProxy4 = null; }
  `);
}

// Drives the accept-path H1 0-RTT test through a dual-family reverse
// proxy. A native DNS override maps the hostname to both ::1 and
// 127.0.0.1 at the proxy port; per-family connect delays pick which
// address wins the HE race. Asserts fetch#1 comes through the std
// path (full handshake) and fetch#2 comes through the early path
// (0-RTT accepted on the winning family), matching the invariants of
// the non-race accept task.
async function runHe0RttRace(host, ipv6DelayMs, ipv4DelayMs) {
  let node = new NodeHTTPServer();
  await node.start();

  const override = Cc["@mozilla.org/network/native-dns-override;1"].getService(
    Ci.nsINativeDNSResolverOverride
  );
  // Race needs both families live; non-race tasks set disableIPv6=true
  // globally. Restored in the finally block below.
  Services.prefs.clearUserPref("network.dns.disableIPv6");
  Services.prefs.clearUserPref("network.dns.localDomains");
  override.addIPOverride(host, "::1");
  override.addIPOverride(host, "127.0.0.1");

  // The earlier accept task cached a resumption ticket for this same
  // host. Without clearing it, this task's "first" fetch would attempt
  // 0-RTT instead of a full handshake, and the race between the two
  // HE attempts over the proxy trips the server's anti-replay guard.
  let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
  await nssComponent.asyncClearSSLExternalAndInternalSessionCache();

  let proxyPort = await startFamilyDelayProxy(node, ipv6DelayMs, ipv4DelayMs);
  const url = `https://${host}:${proxyPort}/`;

  try {
    resetCounts();

    let r1 = await fetchExpect200(url);
    Assert.equal(r1.status, 200, "First fetch should succeed");
    Assert.equal(r1.resumed, false, "First fetch is a full handshake");

    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    await new Promise(resolve => setTimeout(resolve, 200));
    Services.obs.notifyObservers(null, "net:cancel-all-connections");
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    await new Promise(resolve => setTimeout(resolve, 100));

    let r2 = await fetchExpect200(url);
    Assert.equal(r2.status, 200, "Second fetch should succeed");
    Assert.equal(r2.resumed, true, "Second fetch should resume the session");

    info(`fetch#1 remote=${r1.remote} resumed=${r1.resumed}`);
    info(`fetch#2 remote=${r2.remote} resumed=${r2.resumed}`);
    return { r1, r2 };
  } finally {
    Services.obs.notifyObservers(null, "net:cancel-all-connections");
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    await new Promise(resolve => setTimeout(resolve, 200));
    await stopFamilyDelayProxy(node);
    await node.stop();
    override.clearOverrides();
    // Restore the non-race tasks' baseline.
    Services.prefs.setBoolPref("network.dns.disableIPv6", true);
  }
}

add_task(
  {
    skip_if: () =>
      AppConstants.MOZ_SYSTEM_NSS ||
      mozinfo.os == "android" ||
      mozinfo.socketprocess_networking,
  },
  async function test_he_h1_0rtt_ipv4_wins_race() {
    // Fetch#1 has no cached session, so no 0-RTT commit. IPv6 fires
    // first but its response is delayed 1s at the proxy; IPv4 backup
    // fires at 250ms and its full handshake completes first. No 0-RTT
    // sibling exists, so the non-0-RTT disqualify policy doesn't
    // engage — IPv4 wins the full handshake (r1.remote = 127.0.0.1).
    //
    // Fetch#2 has a cached ticket. HE again fires IPv6 first and
    // ZeroRttHandle commits the 0-RTT attempt to that (IPv6) HT. The
    // IPv4 backup fires without 0-RTT and is disqualified by
    // HappyEyeballsTransaction::Close once it finishes a plain
    // handshake. Firefox waits for IPv6 to complete on the wire, so
    // r2.resumed = true. (r2.remote ends up empty because the
    // channel's remoteAddress isn't populated when the connection is
    // promoted through HappyEyeballsTransaction.)
    let { r1 } = await runHe0RttRace("0rtt-accept-h1.example.com", 1000, 0);
    Assert.equal(r1.remote, "127.0.0.1", "fetch#1 should win on IPv4");
  }
);

add_task(
  {
    skip_if: () =>
      AppConstants.MOZ_SYSTEM_NSS ||
      mozinfo.os == "android" ||
      mozinfo.socketprocess_networking,
  },
  async function test_he_h1_0rtt_ipv6_wins_race() {
    // IPv6 fires first and is unimpeded; IPv4 backup is delayed and
    // never matters. Fetch#1 goes over IPv6; fetch#2 resumes via
    // 0-RTT on the same family.
    let { r1 } = await runHe0RttRace("0rtt-accept-h1.example.com", 0, 1000);
    Assert.equal(r1.remote, "::1", "fetch#1 wins on IPv6");
  }
);
