178 lines · 6.3 KB · TypeScript
RawDownload
/* oxlint-disable executor/no-try-catch-or-throw, executor/no-unknown-error-message, executor/no-instanceof-error, executor/no-instanceof-tagged-error -- boundary: git protocol failures normalize to AppSourceError */import { Effect, Schema } from "effect"; import { handFetch, handRefsCheck } from "../git-client/hand";import { hasTokenLikeQuery, redactUrl, validateGitFetchUrl } from "../git-client/url-security";import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";import { AppSourceError, type AppSourceSnapshot } from "./app-source";import {  classifyAppSourcePath,  isRelevantAppSourcePath,  type SourceSkippedFile,} from "./relevant-files"; export interface GitAppSourceInput {  readonly url: string;  readonly ref?: string;  readonly token?: string | null;  readonly fetch?: typeof globalThis.fetch;  readonly maxBytes?: number;  readonly allowPrivateHosts?: boolean;} export interface GitAppSourceSnapshot extends AppSourceSnapshot {  readonly url: string;  readonly ref?: string;  readonly resolvedRef: string;  readonly skipped: readonly SourceSkippedFile[];  readonly packBytes: number;} export interface GitRefsSnapshot {  readonly sourceRef: string;  readonly resolvedRef: string;} const textDecoder = new TextDecoder();const decodeExecutorJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); export const parseGitSourceUrl = (  input: string,  options: { readonly allowPrivateHosts?: boolean } = {},): Effect.Effect<URL, AppSourceError> =>  Effect.try({    try: () => {      const url = validateGitFetchUrl(input.trim(), options);      if (hasTokenLikeQuery(url)) {        throw new AppSourceError({          message: "git source URL must not include token query parameters",          path: redactUrl(input),        });      }      return url;    },    catch: (cause) =>      cause instanceof AppSourceError        ? cause        : new AppSourceError({            message: cause instanceof Error ? cause.message : "git source URL is not valid",            path: redactUrl(input),            cause,          }),  }); const descriptionFor = (  files: readonly PublishFile[],): Effect.Effect<string | undefined, AppSourceError> =>  Effect.gen(function* () {    const file = files.find((item) => item.path === "executor.json");    if (!file) return undefined;    const parsed = yield* decodeExecutorJson(textDecoder.decode(file.bytes)).pipe(      Effect.mapError(        (cause) =>          new AppSourceError({            message: "executor.json is not valid JSON",            path: "executor.json",            cause,          }),      ),    );    if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;    const description = (parsed as { readonly description?: unknown }).description;    return typeof description === "string" ? description : undefined;  }); const relevantFiles = (  files: readonly { readonly path: string; readonly bytes: Uint8Array }[],): { readonly files: readonly PublishFile[]; readonly skipped: readonly SourceSkippedFile[] } => {  const out: PublishFile[] = [];  const skipped: SourceSkippedFile[] = [];  for (const file of files) {    if (!isRelevantAppSourcePath(file.path)) {      const classified = classifyAppSourcePath(file.path);      if (classified !== "fetch") skipped.push(classified);      continue;    }    out.push({ path: file.path, bytes: file.bytes });  }  return { files: out, skipped };}; export const checkGitAppSourceRefs = (  input: GitAppSourceInput,): Effect.Effect<GitRefsSnapshot, AppSourceError> =>  Effect.gen(function* () {    yield* parseGitSourceUrl(input.url, { allowPrivateHosts: input.allowPrivateHosts });    const checked = yield* Effect.tryPromise({      try: () =>        handRefsCheck(input.url, input.ref, {          ...(input.token ? { token: input.token } : {}),          ...(input.fetch ? { fetchImpl: input.fetch } : {}),          allowPrivateHosts: input.allowPrivateHosts,        }),      catch: (cause) =>        new AppSourceError({          message: `git refs check failed: ${cause instanceof Error ? cause.message : String(cause)}`,          path: redactUrl(input.url),          cause,        }),    });    if (!checked.ok || !checked.sha || !checked.resolvedRef) {      return yield* new AppSourceError({        message: checked.error ?? "git refs check failed",        path: redactUrl(input.url),      });    }    return { sourceRef: checked.sha, resolvedRef: checked.resolvedRef };  }); export const fetchGitAppSource = (  input: GitAppSourceInput,): Effect.Effect<GitAppSourceSnapshot, AppSourceError | PublishError> =>  Effect.gen(function* () {    yield* parseGitSourceUrl(input.url, { allowPrivateHosts: input.allowPrivateHosts });    const fetched = yield* Effect.tryPromise({      try: () =>        handFetch(input.url, input.ref, {          ...(input.token ? { token: input.token } : {}),          ...(input.fetch ? { fetchImpl: input.fetch } : {}),          ...(input.maxBytes ? { maxBytes: input.maxBytes } : {}),          allowPrivateHosts: input.allowPrivateHosts,        }),      catch: (cause) =>        new AppSourceError({          message: `git fetch failed: ${cause instanceof Error ? cause.message : String(cause)}`,          path: redactUrl(input.url),          cause,        }),    });    if (fetched.truncated === true) {      return yield* new AppSourceError({        message: fetched.error ?? "git repository is too large",        path: redactUrl(input.url),      });    }    if (!fetched.ok || !fetched.sha || !fetched.resolvedRef || !fetched.files) {      return yield* new AppSourceError({        message: fetched.error ?? "git fetch failed",        path: redactUrl(input.url),      });    }    const collected = relevantFiles(fetched.files);    const limitError = enforcePublishLimits(collected.files);    if (limitError) return yield* limitError;    return {      url: input.url,      ...(input.ref ? { ref: input.ref } : {}),      resolvedRef: fetched.resolvedRef,      files: collected.files,      sourceRef: fetched.sha,      description: yield* descriptionFor(collected.files),      skipped: collected.skipped,      packBytes: fetched.packBytes ?? 0,    };  }); export const makeGitAppSource = (input: GitAppSourceInput) => ({  fetch: () => fetchGitAppSource(input),});