protocol.ts (14.62 KB)
import { getRepoDOStub } from "@/do/repo";
import { PktLine } from "./pkt";
import type { RefUpdateResult } from "./service";

export async function advertiseCapabilities(
  service: "git-upload-pack" | "git-receive-pack",
  fullRepoName: string
) {
  if (service === "git-upload-pack") {
    const lines = [
      PktLine.encode("version 2\n"),
      PktLine.encode("agent=gitflare/0.0.1\n"),

      PktLine.encode("ls-refs\n"),
      PktLine.encode("fetch\n"),
      PktLine.encode("side-band-64k\n"),
      PktLine.encode("object-format=sha1\n"),
      PktLine.encodeFlush(),
    ];

    const response = PktLine.decodeText(PktLine.mergeLines(lines));
    return new Response(response, {
      status: 200,
      headers: {
        "Content-Type": "application/x-git-upload-pack-advertisement",
        "Cache-Control": "no-cache",
      },
    });
  }

  // TODO: Upgrade to use v2 protocol (Keeping this on old protocol for now because of time constraints, I have exams to study for T_T)
  if (service === "git-receive-pack") {
    const capabilities = [
      "report-status",
      "delete-refs",
      "atomic",
      "no-thin",
      "agent=gitflare/0.0.1",
    ];

    const stub = getRepoDOStub(fullRepoName);
    const { refs, symbolicHead } = await stub.listRefs();

    if (symbolicHead) {
      capabilities.push(`symref=HEAD:${symbolicHead}`);
    }

    const capabilitiesStr = capabilities.join(" ");

    const lines = [
      PktLine.encode("# service=git-receive-pack\n"),
      PktLine.encodeFlush(),
    ];

    if (refs.length > 0) {
      const first = refs[0];
      lines.push(
        PktLine.encode(`${first.oid} ${first.ref}\0${capabilitiesStr}\n`)
      );
      for (let i = 1; i < refs.length; i += 1) {
        lines.push(PktLine.encode(`${refs[i].oid} ${refs[i].ref}\n`));
      }
    } else {
      // Empty repository - advertise capabilities with zero OID
      const zeroOid = "0".repeat(40);
      lines.push(
        PktLine.encode(`${zeroOid} capabilities^{}\0${capabilitiesStr}\n`)
      );
    }

    lines.push(PktLine.encodeFlush());

    const response = PktLine.decodeText(PktLine.mergeLines(lines));
    return new Response(response, {
      status: 200,
      headers: {
        "Content-Type": "application/x-git-receive-pack-advertisement",
        "Cache-Control": "no-cache",
      },
    });
  }
}

export type Command = {
  oldOid: string;
  newOid: string;
  ref: string;
};

export function parseReceivePackRequest(data: Uint8Array) {
  const commands: Command[] = [];
  let capabilities: string[] = [];

  let offset = 0;
  while (offset < data.length) {
    const packet = PktLine.decode(data.subarray(offset));

    const lengthHex = PktLine.decodeText(data.slice(offset, offset + 4));
    const specialPackets = [PktLine.DELIM, PktLine.FLUSH, PktLine.RESPONSE_END];
    const packetLength = specialPackets.includes(lengthHex)
      ? 4
      : Number.parseInt(lengthHex, 16);

    offset += packetLength;

    if (packet.type === "flush") {
      // End of commands, packfile follows
      break;
    }

    if (packet.type === "data") {
      const line = PktLine.decodeText(packet.data).trim();

      // Parse: <old-oid> <new-oid> <ref>\0<capabilities>
      const nullIdx = line.indexOf("\0");
      const refLine = nullIdx >= 0 ? line.substring(0, nullIdx) : line;
      const caps = nullIdx >= 0 ? line.substring(nullIdx + 1).split(" ") : [];

      const parts = refLine.split(" ");
      if (parts.length >= 3) {
        commands.push({
          oldOid: parts[0],
          newOid: parts[1],
          ref: parts[2],
        });
      }

      if (caps.length > 0 && capabilities.length === 0) {
        capabilities = caps;
      }
    }
  }

  const packfile = data.subarray(offset);

  return { commands, capabilities, packfile };
}

export async function buildReportStatus(
  results: RefUpdateResult[],
  unpackOk: boolean
) {
  const lines: Uint8Array[] = [];

  if (unpackOk) {
    lines.push(PktLine.encode("unpack ok\n"));
  } else {
    const error = results.find((r) => r.ref === "*")?.error ?? "unknown error";
    lines.push(PktLine.encode(`unpack ${error}\n`));
  }

  for (const result of results) {
    if (result.ref === "*") continue; // Skip unpack status

    if (result.ok) {
      lines.push(PktLine.encode(`ok ${result.ref}\n`));
    } else {
      lines.push(PktLine.encode(`ng ${result.ref} ${result.error}\n`));
    }
  }

  lines.push(PktLine.encodeFlush());

  return new Response(PktLine.decodeText(PktLine.mergeLines(lines)), {
    status: 200,
    headers: {
      "Content-Type": "application/x-git-receive-pack-result",
      "Cache-Control": "no-cache",
    },
  });
}

export function parseCommand(data: Uint8Array) {
  let command = "";
  const args: string[] = [];
  let beforeDelim = true;

  let offset = 0;
  while (offset < data.length) {
    const packet = PktLine.decode(data.subarray(offset));

    const lengthHex = PktLine.decodeText(data.slice(offset, offset + 4));
    const specialPackets = [PktLine.DELIM, PktLine.FLUSH, PktLine.RESPONSE_END];
    const packetLength = specialPackets.includes(lengthHex)
      ? 4
      : Number.parseInt(lengthHex, 16);

    offset += packetLength;

    if (packet.type === "delim") {
      beforeDelim = false;
      continue;
    }

    if (packet.type === "flush" || packet.type === "response-end") {
      break;
    }

    if (packet.type === "data") {
      const line = PktLine.decodeText(packet.data).replace(/\r?\n$/, "");

      if (beforeDelim) {
        if (line.startsWith("command=")) {
          command = line.replace("command=", "");
        }
      } else {
        args.push(line);
      }
    }
  }

  // Fallback: try to extract command from raw text if not found
  if (!command) {
    const text = PktLine.decodeText(data);
    const match = text.match(/command=([a-z-]+)/);
    command = match ? match[1] : "";
  }

  return { command, args };
}

export type FetchRequest = {
  wants: string[];
  haves: string[];
  done: boolean;
  capabilities: {
    thinPack: boolean;
    noProgress: boolean;
    includeTag: boolean;
    ofsDelta: boolean;
    sidebandAll: boolean;
  };
  shallowOptions?: {
    shallow: string[];
    deepen?: number;
    deepenRelative?: boolean;
    deepenSince?: number;
    deepenNot?: string[];
  };
  filterSpec?: string;
};

export function parseFetchRequest(
  _data: Uint8Array,
  args: string[]
): FetchRequest {
  const wants: string[] = [];
  const haves: string[] = [];
  let done = false;
  const capabilities = {
    thinPack: false,
    noProgress: false,
    includeTag: false,
    ofsDelta: false,
    sidebandAll: false,
  };
  const shallow: string[] = [];
  let deepen: number | undefined;
  let deepenRelative = false;
  let deepenSince: number | undefined;
  const deepenNot: string[] = [];
  let filterSpec: string | undefined;

  // Parse arguments from the command section
  for (const arg of args) {
    if (arg.startsWith("want ")) {
      wants.push(arg.slice("want ".length));
    } else if (arg.startsWith("have ")) {
      haves.push(arg.slice("have ".length));
    } else if (arg === "done") {
      done = true;
    } else if (arg === "thin-pack") {
      capabilities.thinPack = true;
    } else if (arg === "no-progress") {
      capabilities.noProgress = true;
    } else if (arg === "include-tag") {
      capabilities.includeTag = true;
    } else if (arg === "ofs-delta") {
      capabilities.ofsDelta = true;
    } else if (arg === "sideband-all") {
      capabilities.sidebandAll = true;
    } else if (arg.startsWith("shallow ")) {
      shallow.push(arg.slice("shallow ".length));
    } else if (arg.startsWith("deepen ")) {
      deepen = Number.parseInt(arg.slice("deepen ".length), 10);
    } else if (arg === "deepen-relative") {
      deepenRelative = true;
    } else if (arg.startsWith("deepen-since ")) {
      deepenSince = Number.parseInt(arg.slice("deepen-since ".length), 10);
    } else if (arg.startsWith("deepen-not ")) {
      deepenNot.push(arg.slice("deepen-not ".length));
    } else if (arg.startsWith("filter ")) {
      filterSpec = arg.slice("filter ".length);
    }
  }

  const shallowOptions =
    shallow.length > 0 ||
    deepen ||
    deepenRelative ||
    deepenSince ||
    deepenNot.length > 0
      ? {
          shallow,
          deepen,
          deepenRelative,
          deepenSince,
          deepenNot,
        }
      : undefined;

  return {
    wants,
    haves,
    done,
    capabilities,
    shallowOptions,
    filterSpec,
  };
}

export async function buildLsRefsResponse(
  refs: Array<{ ref: string; oid: string }>,
  args: string[],
  symbolicHead: string | null,
  readObject: (
    oid: string
  ) => Promise<{ type: string; object: Uint8Array | string } | null>
) {
  const lines: Uint8Array[] = [];

  // Parse arguments
  const refPrefixes: string[] = [];
  let includePeel = false;
  let includeSymrefs = false;

  for (const arg of args) {
    if (arg === "peel") {
      includePeel = true;
    } else if (arg === "symrefs") {
      includeSymrefs = true;
    } else if (arg.startsWith("ref-prefix ")) {
      refPrefixes.push(arg.slice("ref-prefix ".length));
    }
  }

  // Filter refs by prefix if specified
  let filteredRefs = refs;
  if (refPrefixes.length > 0) {
    filteredRefs = refs.filter((ref) =>
      refPrefixes.some((prefix) => ref.ref.startsWith(prefix))
    );
  }

  for (const { ref, oid } of filteredRefs) {
    let line = `${oid} ${ref}`;

    // Add symref attribute if requested and this is HEAD
    if (includeSymrefs && ref === "HEAD" && symbolicHead) {
      line += ` symref-target:${symbolicHead}`;
    }

    lines.push(PktLine.encode(`${line}\n`));

    // Add peeled reference for annotated tags if requested
    if (includePeel && ref.startsWith("refs/tags/")) {
      const obj = await readObject(oid);
      if (obj && obj.type === "tag") {
        // Parse the tag object to get the target commit
        const tagContent =
          typeof obj.object === "string"
            ? obj.object
            : new TextDecoder().decode(obj.object);
        const objectMatch = tagContent.match(/^object ([0-9a-f]{40})/m);
        if (objectMatch) {
          const peeledOid = objectMatch[1];
          lines.push(PktLine.encode(`${peeledOid} ${ref}^{}\n`));
        }
      }
    }
  }

  lines.push(PktLine.encodeFlush());

  // @ts-expect-error ts is complaining that Uint8Array is not assignable to BodyInit
  return new Response(PktLine.mergeLines(lines), {
    status: 200,
    headers: {
      "Content-Type": "application/x-git-upload-pack-result",
      "Cache-Control": "no-cache",
    },
  });
}

/**
 * Parse packfile header to extract object count.
 * Packfile format: 'PACK' + version (4 bytes) + object count (4 bytes)
 *
 * @param packfile - The packfile data
 * @returns Object count, or null if header is invalid
 */
function parsePackfileObjectCount(packfile: Uint8Array): number | null {
  // Check minimum size: 'PACK' (4) + version (4) + count (4) = 12 bytes
  if (packfile.length < 12) {
    return null;
  }

  // Verify 'PACK' signature
  const signature = new TextDecoder().decode(packfile.slice(0, 4));
  if (signature !== "PACK") {
    return null;
  }

  // Read object count (big-endian 32-bit integer at offset 8)
  // Using DataView for cleaner binary parsing
  const view = new DataView(
    packfile.buffer,
    packfile.byteOffset,
    packfile.byteLength
  );
  const count = view.getUint32(8, false); // false = big-endian

  return count;
}

export type FetchResponseOptions = {
  commonCommits: string[];
  packfileData: Uint8Array | null | undefined;
  noProgress: boolean;
  done: boolean;
  objectCount?: number;
};

export async function buildFetchResponse(options: FetchResponseOptions) {
  const lines: Uint8Array[] = [];
  const { commonCommits, packfileData, noProgress, done } = options;

  // Protocol v2 spec: If client sent "done", acknowledgments section MUST be omitted
  if (!done) {
    // Acknowledgments section (only sent during negotiation, not when done=true)
    lines.push(PktLine.encode("acknowledgments\n"));

    if (commonCommits.length === 0) {
      lines.push(PktLine.encode("NAK\n"));
    } else {
      for (const oid of commonCommits) {
        lines.push(PktLine.encode(`ACK ${oid}\n`));
      }
    }

    // Send "ready" to indicate server is ready to send packfile
    lines.push(PktLine.encode("ready\n"));

    // Delimiter separates acknowledgments section from packfile section
    lines.push(PktLine.encodeDelim());
  }

  // Packfile section
  if (packfileData && packfileData.length > 0) {
    // Packfile section header - required by protocol v2
    lines.push(PktLine.encode("packfile\n"));

    // Parse object count from packfile header
    const objectCount =
      options.objectCount ?? parsePackfileObjectCount(packfileData);

    // Send progress messages if not suppressed
    if (!noProgress && objectCount !== null) {
      lines.push(
        PktLine.encodeProgress(
          `remote: Counting objects: ${objectCount}, done.\r\n`
        )
      );
      lines.push(
        PktLine.encodeProgress(
          `remote: Compressing objects: 100% (${objectCount}/${objectCount}), done.\r\n`
        )
      );
    }

    // Split packfile into chunks and multiplex with side-band
    // Side-band format: pkt-line(stream-code + data)
    // Stream code: 1 = pack data, 2 = progress, 3 = error
    for (
      let offset = 0;
      offset < packfileData.length;
      offset += PktLine.MAX_SIDEBAND_PAYLOAD
    ) {
      const end = Math.min(
        offset + PktLine.MAX_SIDEBAND_PAYLOAD,
        packfileData.length
      );
      const chunk = packfileData.subarray(offset, end);

      lines.push(
        PktLine.encodeSideband(PktLine.SIDEBAND_CHANNEL_PACKFILE, chunk)
      );
    }

    // Send final progress message if not suppressed
    if (!noProgress && objectCount !== null) {
      lines.push(
        PktLine.encodeProgress(
          `remote: Total ${objectCount} (delta 0), reused ${objectCount} (delta 0), pack-reused 0        \r\n`
        )
      );
    }

    lines.push(PktLine.encodeFlush());
  }

  // @ts-expect-error ts is complaining that Uint8Array is not assignable to BodyInit
  return new Response(PktLine.mergeLines(lines), {
    status: 200,
    headers: {
      "Content-Type": "application/x-git-upload-pack-result",
      "Cache-Control": "no-cache",
    },
  });
}

export function getBasicCredentials(
  req: Request
): { username: string; password: string } | null {
  const header = req.headers.get("Authorization") || "";
  const match = /^Basic\s+(.+)$/i.exec(header);
  if (!match) return null;
  try {
    const decoded = atob(match[1]);
    const idx = decoded.indexOf(":");
    if (idx === -1) return { username: decoded, password: "" };
    const username = decoded.slice(0, idx);
    const password = decoded.slice(idx + 1);
    return { username, password };
  } catch {
    return null;
  }
}