waysact/webpack-subresource-integrity

View on GitHub
webpack-subresource-integrity/src/plugin.ts

Summary

Maintainability
A
0 mins
Test Coverage
/**
 * Copyright (c) 2015-present, Waysact Pty Ltd
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

import type { AssetInfo, Chunk, Compiler, Compilation, sources } from "webpack";
import { relative, join } from "path";
import { readFileSync } from "fs";
import {
  HtmlTagObject,
  SubresourceIntegrityPluginResolvedOptions,
  StronglyConnectedComponent,
  AssetType,
  TemplateFiles,
  HWPAssets,
  WSIHWPAssets,
  WSIHWPAssetsIntegrityKey,
} from "./types";
import { Reporter } from "./reporter";
import {
  assert,
  computeIntegrity,
  makePlaceholder,
  findChunks,
  normalizePath,
  getTagSrc,
  notNil,
  sriHashVariableReference,
  updateAsset,
  tryGetSource,
  replaceInSource,
  usesAnyHash,
} from "./util";
import { getChunkToManifestMap } from "./manifest";
import { AssetIntegrity } from "./integrity";

const assetTypeIntegrityKeys: [AssetType, WSIHWPAssetsIntegrityKey][] = [
  ["js", "jsIntegrity"],
  ["css", "cssIntegrity"],
];

export class Plugin {
  /**
   * @internal
   */
  private readonly compilation: Compilation;

  /**
   * @internal
   */
  private readonly options: SubresourceIntegrityPluginResolvedOptions;

  /**
   * @internal
   */
  private readonly reporter: Reporter;

  /**
   * @internal
   */
  private assetIntegrity: AssetIntegrity;

  /**
   * @internal
   */
  private hwpPublicPath: string | null = null;

  /**
   * @internal
   */
  private sortedSccChunks: StronglyConnectedComponent<Chunk>[] = [];

  /**
   * @internal
   */
  private chunkManifest: Map<Chunk, Set<Chunk>> = new Map<Chunk, Set<Chunk>>();

  /**
   * @internal
   */
  private hashByPlaceholder = new Map<string, string>();

  public constructor(
    compilation: Compilation,
    options: SubresourceIntegrityPluginResolvedOptions,
    reporter: Reporter
  ) {
    this.compilation = compilation;
    this.options = options;
    this.reporter = reporter;

    this.assetIntegrity = new AssetIntegrity(this.options.hashFuncNames);
  }

  /**
   * @internal
   */
  private warnIfHotUpdate(source: string | Buffer): void {
    if (source.indexOf("webpackHotUpdate") >= 0) {
      this.reporter.warnHotReloading();
    }
  }

  /**
   * @internal
   */
  addMissingIntegrityHashes = (
    assets: Record<string, sources.Source>
  ): void => {
    Object.entries(assets).forEach(([assetKey, asset]) => {
      const source = tryGetSource(asset);
      if (source && !this.assetIntegrity.has(assetKey)) {
        this.assetIntegrity.updateFromSource(assetKey, source);
      }
    });
  };

  /**
   * @internal
   */
  private replaceAsset = (
    compiler: Compiler,
    assets: Record<string, sources.Source>,
    hashByPlaceholder: Map<string, string>,
    chunkFile: string
  ): sources.Source => {
    const asset = assets[chunkFile];
    assert(asset, `Missing asset for file ${chunkFile}`);
    return replaceInSource(compiler, asset, chunkFile, hashByPlaceholder);
  };

  private warnAboutLongTermCaching = (assetInfo: AssetInfo) => {
    if (
      usesAnyHash(assetInfo) &&
      !(
        assetInfo.contenthash &&
        this.compilation.compiler.options.optimization.realContentHash
      )
    ) {
      this.reporter.warnContentHash();
    }
  };

  /**
   * @internal
   */
  private processChunk = (
    chunk: Chunk,
    assets: Record<string, sources.Source>
  ): void => {
    Array.from(findChunks(chunk))
      .reverse()
      .forEach((chunk) => this.processChunkAssets(chunk, assets));
  };

  private processChunkAssets = (
    childChunk: Chunk,
    assets: Record<string, sources.Source>
  ) => {
    Array.from(childChunk.files).forEach((sourcePath) => {
      const asset = assets[sourcePath];
      if (asset) {
        this.warnIfHotUpdate(asset.source());
        const newAsset = this.replaceAsset(
          this.compilation.compiler,
          assets,
          this.hashByPlaceholder,
          sourcePath
        );
        const integrity = this.assetIntegrity.updateFromSource(
          sourcePath,
          newAsset.source()
        );

        if (childChunk.id !== null) {
          this.hashByPlaceholder.set(
            makePlaceholder(this.options.hashFuncNames, childChunk.id),
            integrity
          );
        }

        updateAsset(
          this.compilation,
          sourcePath,
          newAsset,
          integrity,
          this.warnAboutLongTermCaching
        );
      } else {
        this.reporter.warnNoAssetsFound(sourcePath, Object.keys(assets));
      }
    });
  };

  /**
   * @internal
   */
  addAttribute = (elName: string, source: string): string => {
    if (!this.compilation.outputOptions.crossOriginLoading) {
      this.reporter.errorCrossOriginLoadingNotSet();
    }

    return this.compilation.compiler.webpack.Template.asString([
      source,
      elName + `.integrity = ${sriHashVariableReference}[chunkId];`,
      elName +
        ".crossOrigin = " +
        JSON.stringify(this.compilation.outputOptions.crossOriginLoading) +
        ";",
    ]);
  };

  /**
   * @internal
   */
  processAssets = (assets: Record<string, sources.Source>): void => {
    if (this.options.hashLoading === "lazy") {
      for (const scc of this.sortedSccChunks) {
        for (const chunk of scc.nodes) {
          this.processChunkAssets(chunk, assets);
        }
      }
    } else {
      Array.from(this.compilation.chunks)
        .filter((chunk) => chunk.hasRuntime())
        .forEach((chunk) => {
          this.processChunk(chunk, assets);
        });
    }

    this.addMissingIntegrityHashes(assets);
  };

  /**
   * @internal
   */
  private hwpAssetPath = (src: string): string => {
    assert(this.hwpPublicPath !== null, "Missing HtmlWebpackPlugin publicPath");
    return relative(this.hwpPublicPath, decodeURIComponent(src));
  };

  /**
   * @internal
   */
  private getIntegrityChecksumForAsset = (
    assets: Record<string, sources.Source>,
    src: string
  ): string | undefined => {
    if (this.assetIntegrity.has(src)) {
      return this.assetIntegrity.get(src);
    }

    const normalizedSrc = normalizePath(src);
    const normalizedKey = Object.keys(assets).find(
      (assetKey) => normalizePath(assetKey) === normalizedSrc
    );

    return normalizedKey ? this.assetIntegrity.get(normalizedKey) : undefined;
  };

  /**
   * @internal
   */
  processTag = (tag: HtmlTagObject): void => {
    if (
      tag.attributes &&
      Object.prototype.hasOwnProperty.call(tag.attributes, "integrity")
    ) {
      return;
    }

    const tagSrc = getTagSrc(tag);

    if (!tagSrc) {
      return;
    }

    const src = this.hwpAssetPath(tagSrc);

    tag.attributes["integrity"] =
      this.getIntegrityChecksumForAsset(this.compilation.assets, src) ||
      computeIntegrity(
        this.options.hashFuncNames,
        readFileSync(join(this.compilation.compiler.outputPath, src))
      );
    tag.attributes["crossorigin"] =
      this.compilation.compiler.options.output.crossOriginLoading ||
      "anonymous";
  };

  /**
   * @internal
   */
  beforeRuntimeRequirements = (): void => {
    if (this.options.hashLoading === "lazy") {
      const [sortedSccChunks, chunkManifest] = getChunkToManifestMap(
        this.compilation.chunks
      );
      this.sortedSccChunks = sortedSccChunks;
      this.chunkManifest = chunkManifest;
    }
    this.hashByPlaceholder.clear();
  };

  getChildChunksToAddToChunkManifest(chunk: Chunk): Set<Chunk> {
    return this.chunkManifest.get(chunk) ?? new Set<Chunk>();
  }

  handleHwpPluginArgs = ({ assets }: { assets: HWPAssets }): void => {
    this.hwpPublicPath = assets.publicPath;

    assetTypeIntegrityKeys.forEach(
      ([a, b]: [AssetType, WSIHWPAssetsIntegrityKey]) => {
        if (b) {
          (assets as WSIHWPAssets)[b] = (assets as TemplateFiles)[a]
            .map((filePath: string) =>
              this.getIntegrityChecksumForAsset(
                this.compilation.assets,
                this.hwpAssetPath(filePath)
              )
            )
            .filter(notNil);
        }
      }
    );
  };

  handleHwpBodyTags = ({
    headTags,
    bodyTags,
  }: {
    headTags: HtmlTagObject[];
    bodyTags: HtmlTagObject[];
  }): void => {
    this.addMissingIntegrityHashes(this.compilation.assets);

    headTags.concat(bodyTags).forEach(this.processTag);
  };

  public updateHash(input: Buffer[], oldHash: string): string | undefined {
    return this.assetIntegrity.updateHash(input, oldHash);
  }
}