/**
 * Regression guard: a git push body must not be able to OOM or stall the
 * server, and must not be touched at all before the pusher is authorized.
 *
 * The bug in POST /:owner/:repo.git/git-receive-pack was an ordering one.
 * The handler did `await c.req.arrayBuffer()` and then `Bun.gunzipSync(...)`
 * — buffering the whole body and expanding it in full — and only AFTER that
 * checked whether the caller had write access. So any unauthenticated caller
 * who knew a repository name (public ones are listed on /explore) could post
 * a gzip bomb and have the server expand it before deciding they had no
 * right to push anything. gzip passes 1000:1 on repetitive input, so a
 * 10 MB upload becomes ~10 GB of resident memory.
 *
 * `gunzipSync` compounded it by being synchronous: decompression blocked the
 * event loop for its whole duration, stalling every other in-flight request.
 * That is a latency DoS on its own, independent of the memory one.
 *
 * Two things are asserted: the decompressor is bounded, and the handler
 * still reads the body only after the access gate.
 */

import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
  gunzipBounded,
  isGzip,
  DecompressedTooLargeError,
  MAX_GIT_BODY_BYTES,
} from "../lib/bounded-gunzip";

describe("isGzip", () => {
  it("detects the gzip magic number", () => {
    expect(isGzip(Bun.gzipSync(new Uint8Array([1, 2, 3])))).toBe(true);
  });

  it("rejects plain and truncated input", () => {
    expect(isGzip(new Uint8Array([]))).toBe(false);
    expect(isGzip(new Uint8Array([0x1f]))).toBe(false);
    expect(isGzip(new TextEncoder().encode("0000"))).toBe(false);
  });
});

describe("gunzipBounded", () => {
  it("round-trips ordinary content unchanged", async () => {
    const original = new TextEncoder().encode(
      "0032want d4a1f9e0c2b3a4d5e6f70819a2b3c4d5e6f70819\n0000"
    );
    const out = await gunzipBounded(Bun.gzipSync(original));
    expect(new TextDecoder().decode(out)).toBe(
      new TextDecoder().decode(original)
    );
  });

  it("round-trips content spanning many internal chunks", async () => {
    // Incompressible-ish payload large enough that the stream yields more
    // than one chunk, exercising the concat path.
    const big = new Uint8Array(3 * 1024 * 1024);
    for (let i = 0; i < big.length; i++) big[i] = (i * 2654435761) & 0xff;
    const out = await gunzipBounded(Bun.gzipSync(big));
    expect(out.byteLength).toBe(big.byteLength);
    expect(out[0]).toBe(big[0]);
    expect(out[big.length - 1]).toBe(big[big.length - 1]);
  });

  it("refuses a gzip bomb instead of expanding it (the attack)", async () => {
    // 64 MiB of zeros compresses to a few tens of KB — a ~1000:1 ratio, the
    // exact shape of the attack. Compressed size is unremarkable; expanded
    // size is what would have killed the process.
    const bomb = Bun.gzipSync(new Uint8Array(64 * 1024 * 1024));
    expect(bomb.byteLength).toBeLessThan(1024 * 1024);

    await expect(gunzipBounded(bomb, 1024 * 1024)).rejects.toBeInstanceOf(
      DecompressedTooLargeError
    );
  });

  it("reports the limit it enforced", async () => {
    const bomb = Bun.gzipSync(new Uint8Array(8 * 1024 * 1024));
    try {
      await gunzipBounded(bomb, 4096);
      throw new Error("should have thrown");
    } catch (err) {
      expect(err).toBeInstanceOf(DecompressedTooLargeError);
      expect((err as DecompressedTooLargeError).limit).toBe(4096);
    }
  });

  it("accepts output that lands exactly on the limit", async () => {
    const payload = new Uint8Array(1024);
    const out = await gunzipBounded(Bun.gzipSync(payload), 1024);
    expect(out.byteLength).toBe(1024);
  });

  it("rejects one byte over the limit", async () => {
    const payload = new Uint8Array(1025);
    await expect(
      gunzipBounded(Bun.gzipSync(payload), 1024)
    ).rejects.toBeInstanceOf(DecompressedTooLargeError);
  });

  it("defaults to a bound that is generous but finite", () => {
    expect(MAX_GIT_BODY_BYTES).toBeGreaterThan(64 * 1024 * 1024);
    expect(Number.isFinite(MAX_GIT_BODY_BYTES)).toBe(true);
  });

  it("surfaces corrupt gzip rather than hanging", async () => {
    const corrupt = Bun.gzipSync(new TextEncoder().encode("hello"));
    corrupt[corrupt.length - 4] ^= 0xff; // wreck the trailing CRC
    await expect(gunzipBounded(corrupt)).rejects.toBeDefined();
  });
});

// --- ordering: authorize before touching the body --------------------------
//
// Strip ONLY line comments. A block-comment regex eats route paths, which
// contain a slash followed by a star — and this file is full of them.

describe("git-receive-pack authorizes before reading the body", () => {
  const src = (() => {
    const text = readFileSync(
      join(import.meta.dir, "..", "routes", "git.ts"),
      "utf8"
    );
    return text
      .split("\n")
      .filter((l) => !l.trim().startsWith("//"))
      .join("\n");
  })();

  const handler = (() => {
    const start = src.indexOf(`git.post("/:owner/:repo.git/git-receive-pack"`);
    expect(start).toBeGreaterThan(-1);
    const body = src.slice(start);
    expect(body.length).toBeGreaterThan(0);
    return body;
  })();

  it("checks write access before arrayBuffer()", () => {
    const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`);
    const readAt = handler.indexOf("c.req.arrayBuffer()");
    expect(gateAt).toBeGreaterThan(-1);
    expect(readAt).toBeGreaterThan(-1);
    expect(gateAt).toBeLessThan(readAt);
  });

  it("checks write access before any decompression", () => {
    const gateAt = handler.indexOf(`satisfiesAccess(access, "write")`);
    const gunzipAt = handler.indexOf("gunzipBounded");
    expect(gunzipAt).toBeGreaterThan(-1);
    expect(gateAt).toBeLessThan(gunzipAt);
  });

  it("no longer uses the unbounded synchronous decompressor", () => {
    expect(handler).not.toContain("gunzipSync");
  });

  it("still parses refs, so policy and post-receive keep working", () => {
    // The reorder moved the body read; it must not have dropped ref parsing,
    // or pushes would silently skip the policy gate and the hook.
    expect(handler).toContain("parseReceivePackRefs");
    const gunzipAt = handler.indexOf("gunzipBounded");
    expect(handler.indexOf("parseReceivePackRefs")).toBeGreaterThan(gunzipAt);
  });
});
