472 lines · 14.1 KB · TypeScript
RawDownload
import * as Effect from "effect/Effect";import * as Schema from "effect/Schema"; import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; export interface UpdateManifestFile {  readonly url: string;  readonly sha512: string;  readonly size: number;} export const UpdateManifestScalar = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]);export type UpdateManifestScalar = typeof UpdateManifestScalar.Type; export const UpdateManifestScalarType = Schema.Literals(["string", "number", "boolean"]);export type UpdateManifestScalarType = typeof UpdateManifestScalarType.Type; export const UpdateManifestParseReason = Schema.Literals([  "incomplete file entry",  "sha512 without a file entry",  "size without a file entry",  "unsupported line",  "version must be a string",  "releaseDate must be a string",  "missing version",  "missing releaseDate",  "missing files",]);export type UpdateManifestParseReason = typeof UpdateManifestParseReason.Type; export class UpdateManifestParseError extends Schema.TaggedErrorClass<UpdateManifestParseError>()(  "UpdateManifestParseError",  {    platformLabel: Schema.String,    sourcePath: Schema.String,    lineNumber: Schema.optionalKey(Schema.Number),    reason: UpdateManifestParseReason,    lineLength: Schema.optionalKey(Schema.Number),  },) {  override get message(): string {    const location =      this.lineNumber === undefined ? this.sourcePath : `${this.sourcePath}:${this.lineNumber}`;    const input = this.lineLength === undefined ? "" : ` Input length: ${this.lineLength}.`;    return `Invalid ${this.platformLabel} update manifest at ${location}: ${this.reason}.${input}`;  }} export class UpdateManifestVersionConflictError extends Schema.TaggedErrorClass<UpdateManifestVersionConflictError>()(  "UpdateManifestVersionConflictError",  {    platformLabel: Schema.String,    primaryVersion: Schema.String,    secondaryVersion: Schema.String,  },) {  override get message(): string {    return `Cannot merge ${this.platformLabel} update manifests with different versions (${this.primaryVersion} vs ${this.secondaryVersion}).`;  }} export class UpdateManifestExtraConflictError extends Schema.TaggedErrorClass<UpdateManifestExtraConflictError>()(  "UpdateManifestExtraConflictError",  {    platformLabel: Schema.String,    key: Schema.String,    primaryValueType: UpdateManifestScalarType,    primaryValueLength: Schema.optionalKey(Schema.Number),    secondaryValueType: UpdateManifestScalarType,    secondaryValueLength: Schema.optionalKey(Schema.Number),  },) {  override get message(): string {    return `Cannot merge ${this.platformLabel} update manifests: conflicting '${this.key}' ${this.primaryValueType} and ${this.secondaryValueType} values.`;  }} export class UpdateManifestFileConflictError extends Schema.TaggedErrorClass<UpdateManifestFileConflictError>()(  "UpdateManifestFileConflictError",  {    platformLabel: Schema.String,    urlInputLength: Schema.Number,    urlProtocol: Schema.optionalKey(Schema.String),    urlHostname: Schema.optionalKey(Schema.String),    existingManifest: Schema.Literals(["primary", "secondary"]),    existingSha512Length: Schema.Number,    existingSize: Schema.Number,    conflictingManifest: Schema.Literals(["primary", "secondary"]),    conflictingSha512Length: Schema.Number,    conflictingSize: Schema.Number,    sha512Conflict: Schema.Boolean,    sizeConflict: Schema.Boolean,  },) {  override get message(): string {    const origin =      this.urlProtocol === undefined || this.urlHostname === undefined        ? ""        : ` at ${this.urlProtocol}//${this.urlHostname}`;    return `Cannot merge ${this.platformLabel} update manifests: conflicting file entry${origin} (URL input length: ${this.urlInputLength}).`;  }} export class UpdateManifestSerializationError extends Schema.TaggedErrorClass<UpdateManifestSerializationError>()(  "UpdateManifestSerializationError",  {    platformLabel: Schema.String,    key: Schema.String,  },) {  override get message(): string {    return `Cannot serialize ${this.platformLabel} update manifest: missing value for '${this.key}'.`;  }} export const UpdateManifestError = Schema.Union([  UpdateManifestParseError,  UpdateManifestVersionConflictError,  UpdateManifestExtraConflictError,  UpdateManifestFileConflictError,  UpdateManifestSerializationError,]);export type UpdateManifestError = typeof UpdateManifestError.Type;export const isUpdateManifestError = Schema.is(UpdateManifestError); export const attemptUpdateManifest = <A>(  evaluate: () => A,): Effect.Effect<A, UpdateManifestError> =>  Effect.suspend(() => {    try {      return Effect.succeed(evaluate());    } catch (error) {      return isUpdateManifestError(error) ? Effect.fail(error) : Effect.die(error);    }  }); export interface UpdateManifest {  readonly version: string;  readonly releaseDate: string;  readonly files: ReadonlyArray<UpdateManifestFile>;  readonly extras: Readonly<Record<string, UpdateManifestScalar>>;} interface MutableUpdateManifestFile {  url?: string;  sha512?: string;  size?: number;} function stripSingleQuotes(value: string): string {  if (value.startsWith("'") && value.endsWith("'")) {    return value.slice(1, -1).replace(/''/g, "'");  }  return value;} function parseFileRecord(  currentFile: MutableUpdateManifestFile | null,  sourcePath: string,  lineNumber: number,  platformLabel: string,): UpdateManifestFile | null {  if (currentFile === null) {    return null;  }  if (    typeof currentFile.url !== "string" ||    typeof currentFile.sha512 !== "string" ||    typeof currentFile.size !== "number"  ) {    throw new UpdateManifestParseError({      platformLabel,      sourcePath,      lineNumber,      reason: "incomplete file entry",    });  }  return {    url: currentFile.url,    sha512: currentFile.sha512,    size: currentFile.size,  };} function parseScalarValue(rawValue: string): UpdateManifestScalar {  const trimmed = rawValue.trim();  const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2;  const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed;  if (isQuoted) return value;  if (value === "true") return true;  if (value === "false") return false;  if (/^-?\d+(?:\.\d+)?$/.test(value)) {    return Number(value);  }  return value;} export function parseUpdateManifest(  raw: string,  sourcePath: string,  platformLabel: string,): UpdateManifest {  const lines = raw.split(/\r?\n/);  const files: UpdateManifestFile[] = [];  const extras: Record<string, UpdateManifestScalar> = {};  let version: string | null = null;  let releaseDate: string | null = null;  let inFiles = false;  let currentFile: MutableUpdateManifestFile | null = null;   for (const [index, rawLine] of lines.entries()) {    const lineNumber = index + 1;    const line = rawLine.trimEnd();    if (line.length === 0) continue;     const fileUrlMatch = line.match(/^  - url:\s*(.+)$/);    if (fileUrlMatch?.[1]) {      const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);      if (finalized) files.push(finalized);      currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) };      inFiles = true;      continue;    }     const fileShaMatch = line.match(/^    sha512:\s*(.+)$/);    if (fileShaMatch?.[1]) {      if (currentFile === null) {        throw new UpdateManifestParseError({          platformLabel,          sourcePath,          lineNumber,          reason: "sha512 without a file entry",        });      }      currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim());      continue;    }     const fileSizeMatch = line.match(/^    size:\s*(\d+)$/);    if (fileSizeMatch?.[1]) {      if (currentFile === null) {        throw new UpdateManifestParseError({          platformLabel,          sourcePath,          lineNumber,          reason: "size without a file entry",        });      }      currentFile.size = Number(fileSizeMatch[1]);      continue;    }     if (line === "files:") {      inFiles = true;      continue;    }     if (inFiles && currentFile !== null) {      const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel);      if (finalized) files.push(finalized);      currentFile = null;    }    inFiles = false;     const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/);    if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) {      throw new UpdateManifestParseError({        platformLabel,        sourcePath,        lineNumber,        reason: "unsupported line",        lineLength: line.length,      });    }     const [, key, rawValue] = topLevelMatch;    const value = parseScalarValue(rawValue);     if (key === "version") {      if (typeof value !== "string") {        throw new UpdateManifestParseError({          platformLabel,          sourcePath,          lineNumber,          reason: "version must be a string",        });      }      version = value;      continue;    }     if (key === "releaseDate") {      if (typeof value !== "string") {        throw new UpdateManifestParseError({          platformLabel,          sourcePath,          lineNumber,          reason: "releaseDate must be a string",        });      }      releaseDate = value;      continue;    }     if (key === "path" || key === "sha512") {      continue;    }     extras[key] = value;  }   const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel);  if (finalized) files.push(finalized);   if (!version) {    throw new UpdateManifestParseError({      platformLabel,      sourcePath,      reason: "missing version",    });  }  if (!releaseDate) {    throw new UpdateManifestParseError({      platformLabel,      sourcePath,      reason: "missing releaseDate",    });  }  if (files.length === 0) {    throw new UpdateManifestParseError({      platformLabel,      sourcePath,      reason: "missing files",    });  }   return {    version,    releaseDate,    files,    extras,  };} function getScalarType(value: UpdateManifestScalar): UpdateManifestScalarType {  if (typeof value === "string") return "string";  if (typeof value === "number") return "number";  return "boolean";} function mergeExtras(  primary: Readonly<Record<string, UpdateManifestScalar>>,  secondary: Readonly<Record<string, UpdateManifestScalar>>,  platformLabel: string,): Record<string, UpdateManifestScalar> {  const merged: Record<string, UpdateManifestScalar> = { ...primary };   for (const [key, value] of Object.entries(secondary)) {    const existing = merged[key];    if (existing !== undefined && existing !== value) {      throw new UpdateManifestExtraConflictError({        platformLabel,        key,        primaryValueType: getScalarType(existing),        ...(typeof existing === "string" ? { primaryValueLength: existing.length } : {}),        secondaryValueType: getScalarType(value),        ...(typeof value === "string" ? { secondaryValueLength: value.length } : {}),      });    }    merged[key] = value;  }   return merged;} export function mergeUpdateManifests(  primary: UpdateManifest,  secondary: UpdateManifest,  platformLabel: string,): UpdateManifest {  if (primary.version !== secondary.version) {    throw new UpdateManifestVersionConflictError({      platformLabel,      primaryVersion: primary.version,      secondaryVersion: secondary.version,    });  }   const filesByUrl = new Map<    string,    { readonly manifest: "primary" | "secondary"; readonly file: UpdateManifestFile }  >();  for (const [manifest, files] of [    ["primary", primary.files],    ["secondary", secondary.files],  ] as const) {    for (const file of files) {      const existing = filesByUrl.get(file.url);      if (existing && (existing.file.sha512 !== file.sha512 || existing.file.size !== file.size)) {        const urlDiagnostics = getUrlDiagnostics(file.url);        throw new UpdateManifestFileConflictError({          platformLabel,          urlInputLength: urlDiagnostics.inputLength,          ...(urlDiagnostics.protocol === undefined            ? {}            : { urlProtocol: urlDiagnostics.protocol }),          ...(urlDiagnostics.hostname === undefined            ? {}            : { urlHostname: urlDiagnostics.hostname }),          existingManifest: existing.manifest,          existingSha512Length: existing.file.sha512.length,          existingSize: existing.file.size,          conflictingManifest: manifest,          conflictingSha512Length: file.sha512.length,          conflictingSize: file.size,          sha512Conflict: existing.file.sha512 !== file.sha512,          sizeConflict: existing.file.size !== file.size,        });      }      filesByUrl.set(file.url, { manifest, file });    }  }   return {    version: primary.version,    releaseDate:      primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate,    files: [...filesByUrl.values()].map(({ file }) => file),    extras: mergeExtras(primary.extras, secondary.extras, platformLabel),  };} function quoteYamlString(value: string): string {  return `'${value.replace(/'/g, "''")}'`;} function serializeScalarValue(value: UpdateManifestScalar): string {  if (typeof value === "string") {    return quoteYamlString(value);  }  return String(value);} export function serializeUpdateManifest(  manifest: UpdateManifest,  options: {    readonly platformLabel: string;  },): string {  const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"];   for (const file of manifest.files) {    lines.push(`  - url: ${file.url}`);    lines.push(`    sha512: ${file.sha512}`);    lines.push(`    size: ${file.size}`);  }   for (const key of Object.keys(manifest.extras).toSorted()) {    const value = manifest.extras[key];    if (value === undefined) {      throw new UpdateManifestSerializationError({        platformLabel: options.platformLabel,        key,      });    }    lines.push(`${key}: ${serializeScalarValue(value)}`);  }   lines.push(`releaseDate: ${quoteYamlString(manifest.releaseDate)}`);  lines.push("");  return lines.join("\n");}