All files / src report-assets.ts

93.9% Statements 77/82
87.23% Branches 41/47
100% Functions 5/5
93.67% Lines 74/79

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170          31x                                             280x 280x 2984x 2984x 2983x 2x               104x 104x 104x 104x 104x 104x 197x 197x 57x 56x 56x 53x 53x 53x 155x 1x 154x   154x 154x 20222x 1x 20221x 20221x 20221x 20221x 3x 20218x   20116x   20116x 1x 20115x 20115x 1x 20114x                   53x 46x   112x     44x 44x     93x               65x 70x 70x 70x             1x 69x         69x 69x 69x             69x 69x 69x 68x 68x 68x 68x 71x           70x   70x 70x 70x           70x   70x   70x   67x     68x     69x        
import { constants } from "node:fs";
import { lstat, open, opendir, realpath, mkdir } from "node:fs/promises";
import { dirname, parse, relative, resolve, sep } from "node:path";
 
/** Resource limits apply across both reports; no provider-specific extension list. */
export const reportAssetLimits = {
  entries: 20_000,
  fileBytes: 128 * 1024 * 1024,
  totalBytes: 512 * 1024 * 1024,
  depth: 64,
} as const;
 
export interface ReportAsset {
  readonly source: string;
  readonly destination: string;
  readonly size: number;
  readonly ino: number;
  readonly dev: number;
}
 
export interface ReportAssets {
  readonly inputs: readonly string[];
  readonly files: readonly ReportAsset[];
  readonly links: { readonly allure?: string; readonly coverage?: string };
}
 
/** Every existing component must be a real directory, including input ancestors. */
async function directories(path: string): Promise<void> {
  let current = parse(path).root;
  for (const part of relative(current, path).split(sep).filter(Boolean)) {
    current = resolve(current, part);
    const info = await lstat(current);
    if (!info.isDirectory() || info.isSymbolicLink())
      throw new Error(`Unsafe report input directory: ${current}`);
  }
}
 
export async function collectReportAssets(
  root: string,
  reports: { readonly allure?: string; readonly coverage?: string },
): Promise<ReportAssets> {
  const inputs: string[] = [];
  const files: ReportAsset[] = [];
  const links: { allure?: string; coverage?: string } = {};
  let entries = 0;
  let bytes = 0;
  for (const kind of ["allure", "coverage"] as const) {
    const requested = reports[kind];
    if (requested === undefined) continue;
    if (!requested.trim()) throw new Error(`Empty ${kind} report directory`);
    const input = resolve(root, requested);
    await directories(input);
    inputs.push(await realpath(input));
    const reportFiles: ReportAsset[] = [];
    const walk = async (directory: string, prefix: string, depth: number) => {
      if (depth > reportAssetLimits.depth)
        throw new Error(`Report exceeds directory depth limit: ${directory}`);
      await directories(directory);
      // Stream enumeration so enormous directories cannot exhaust memory.
      const dir = await opendir(directory);
      for await (const entry of dir) {
        if (++entries > reportAssetLimits.entries)
          throw new Error("Report exceeds entry count limit");
        const source = resolve(directory, entry.name);
        const destination = `${prefix}/${entry.name}`;
        const info = await lstat(source);
        if (info.isSymbolicLink())
          throw new Error(`Unsafe report asset symlink: ${source}`);
        if (info.isDirectory()) await walk(source, destination, depth + 1);
        else {
          Iif (!info.isFile())
            throw new Error(`Unsafe report asset type: ${source}`);
          if (info.size > reportAssetLimits.fileBytes)
            throw new Error(`Report exceeds file size limit: ${source}`);
          bytes += info.size;
          if (bytes > reportAssetLimits.totalBytes)
            throw new Error("Report exceeds total size limit");
          reportFiles.push({
            source,
            destination,
            size: info.size,
            ino: info.ino,
            dev: info.dev,
          });
        }
      }
    };
    await walk(input, kind, 0);
    if (
      reportFiles.some(
        (file) => file.destination === `${kind}/index.html` && file.size > 0,
      )
    ) {
      files.push(...reportFiles);
      links[kind] = `./${kind}/`;
    }
  }
  return { inputs, files, links };
}
 
/** Snapshot regular files into private staging with bounded reads and exclusive writes. */
export async function stageReportAssets(
  stage: string,
  assets: readonly ReportAsset[],
): Promise<void> {
  for (const asset of assets) {
    await directories(dirname(asset.source));
    const info = await lstat(asset.source);
    if (
      !info.isFile() ||
      info.isSymbolicLink() ||
      info.ino !== asset.ino ||
      info.dev !== asset.dev ||
      info.size !== asset.size
    )
      throw new Error(`Report asset changed before copying: ${asset.source}`);
    const source = await open(
      asset.source,
      constants.O_RDONLY |
        (process.platform === "win32" ? 0 : constants.O_NOFOLLOW),
    );
    try {
      const opened = await source.stat();
      Iif (
        !opened.isFile() ||
        opened.ino !== asset.ino ||
        opened.dev !== asset.dev ||
        opened.size !== asset.size
      )
        throw new Error(`Report asset changed while opening: ${asset.source}`);
      const destination = resolve(stage, asset.destination);
      await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
      const target = await open(destination, "wx", 0o600);
      try {
        const buffer = Buffer.alloc(64 * 1024);
        let offset = 0;
        while (offset < asset.size) {
          const { bytesRead } = await source.read(
            buffer,
            0,
            Math.min(buffer.length, asset.size - offset),
            offset,
          );
          Iif (!bytesRead)
            throw new Error(`Report asset truncated: ${asset.source}`);
          let written = 0;
          while (written < bytesRead) {
            const result = await target.write(
              buffer,
              written,
              bytesRead - written,
              offset + written,
            );
            Iif (!result.bytesWritten)
              throw new Error(`Could not copy report asset: ${asset.source}`);
            written += result.bytesWritten;
          }
          offset += bytesRead;
        }
        Iif ((await source.stat()).size !== asset.size)
          throw new Error(`Report asset size changed: ${asset.source}`);
      } finally {
        await target.close();
      }
    } finally {
      await source.close();
    }
  }
}