49 lines · 2.2 KB
/** A parsed `uses:` reference to a non-builtin action. */
export type ActionReference =
  | { kind: "remote"; owner: string; repo: string; path: string; ref: string }
  | { kind: "local"; path: string }
  | { kind: "docker"; image: string };

const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
const REPO = /^[A-Za-z0-9._-]{1,100}$/;
const SEGMENT = /^[A-Za-z0-9._-]{1,255}$/;
const REF = /^[A-Za-z0-9._\/+-]{1,255}$/;
export const FULL_SHA = /^[0-9a-f]{40}$/;
/** SHA-256 object ids, for repositories converted to the newer object format. */
const FULL_SHA256 = /^[0-9a-f]{64}$/;

function safePath(segments: string[]): boolean {
  return segments.every((segment) => SEGMENT.test(segment) && segment !== "." && segment !== "..");
}

export function parseActionReference(raw: string): ActionReference {
  const uses = raw.trim();
  if (uses.startsWith("docker://")) return { kind: "docker", image: uses.slice("docker://".length) };
  if (uses.startsWith("./")) {
    const segments = uses.slice(2).split("/").filter(Boolean);
    if (segments.length > 0 && !safePath(segments)) throw new Error(`invalid local action path '${uses}'`);
    return { kind: "local", path: segments.join("/") };
  }
  const at = uses.lastIndexOf("@");
  if (at <= 0) throw new Error(`action '${uses}' needs a version, like owner/repo@v1 or owner/repo@<commit sha>`);
  const ref = uses.slice(at + 1);
  const [owner = "", repo = "", ...rest] = uses.slice(0, at).split("/");
  if (!OWNER.test(owner) || !REPO.test(repo) || repo === "." || repo === "..") {
    throw new Error(`invalid action reference '${uses}'`);
  }
  if (!REF.test(ref) || ref.includes("..") || ref.startsWith("/") || ref.endsWith("/")) {
    throw new Error(`invalid action version '${ref}' in '${uses}'`);
  }
  if (rest.length > 0 && !safePath(rest)) throw new Error(`invalid action path in '${uses}'`);
  return { kind: "remote", owner, repo: repo.replace(/\.git$/, ""), path: rest.join("/"), ref };
}