From ef242b52633efe782339fdd8c9b7728679015689 Mon Sep 17 00:00:00 2001 From: LingyuCoder Date: Tue, 11 Feb 2025 19:50:41 +0800 Subject: [PATCH] feat(sri): add native plugin --- crates/rspack_plugin_sri/src/asset.rs | 104 ++++-- crates/rspack_plugin_sri/src/lib.rs | 20 +- crates/rspack_plugin_sri/src/runtime.rs | 28 +- .../rspack/src/builtin-plugin/SRIPlugin.ts | 51 ++- pnpm-lock.yaml | 82 +++-- tests/plugin-test/jest.config.js | 9 +- tests/plugin-test/package.json | 7 +- tests/plugin-test/sri-plugin/README.md | 9 + .../__fixtures__/simple-project/src/index.js | 2 + .../__fixtures__/unresolved/src/index.js | 1 + .../__mocks__/html-webpack-plugin.js | 5 + .../plugin-test/sri-plugin/hwp-error.test.ts | 28 ++ .../sri-plugin/integration.test.ts | 94 +++++ tests/plugin-test/sri-plugin/test-utils.ts | 24 ++ tests/plugin-test/sri-plugin/unit.test.ts | 344 ++++++++++++++++++ tests/plugin-test/tsconfig.json | 16 + 16 files changed, 723 insertions(+), 101 deletions(-) create mode 100644 tests/plugin-test/sri-plugin/README.md create mode 100644 tests/plugin-test/sri-plugin/__fixtures__/simple-project/src/index.js create mode 100644 tests/plugin-test/sri-plugin/__fixtures__/unresolved/src/index.js create mode 100644 tests/plugin-test/sri-plugin/__mocks__/html-webpack-plugin.js create mode 100644 tests/plugin-test/sri-plugin/hwp-error.test.ts create mode 100644 tests/plugin-test/sri-plugin/integration.test.ts create mode 100644 tests/plugin-test/sri-plugin/test-utils.ts create mode 100644 tests/plugin-test/sri-plugin/unit.test.ts create mode 100644 tests/plugin-test/tsconfig.json diff --git a/crates/rspack_plugin_sri/src/asset.rs b/crates/rspack_plugin_sri/src/asset.rs index 4f8924fa5d72..98b08b9ac282 100644 --- a/crates/rspack_plugin_sri/src/asset.rs +++ b/crates/rspack_plugin_sri/src/asset.rs @@ -4,7 +4,8 @@ use rayon::iter::{IntoParallelRefIterator, ParallelBridge, ParallelIterator}; use rspack_core::{ chunk_graph_chunk::ChunkId, rspack_sources::{ReplaceSource, Source}, - ChunkUkey, Compilation, CompilationAssets, CompilationProcessAssets, CrossOriginLoading, + ChunkUkey, Compilation, CompilationAfterProcessAssets, CompilationAssets, + CompilationProcessAssets, CrossOriginLoading, }; use rspack_error::{Diagnostic, Result}; use rspack_hook::plugin_hook; @@ -14,9 +15,11 @@ use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use crate::{ config::IntegrityHtmlPlugin, integrity::{compute_integrity, SRIHashFunction}, - util::{make_placeholder, use_any_hash, PLACEHOLDER_REGEX}, + util::{make_placeholder, use_any_hash, PLACEHOLDER_PREFIX, PLACEHOLDER_REGEX}, IntegrityCallbackData, SRIPlugin, SRIPluginInner, }; + +#[derive(Debug, Clone)] struct ProcessChunkResult { pub file: String, pub source: Option>, @@ -40,7 +43,7 @@ fn process_chunks( compilation.push_diagnostic(Diagnostic::warn( "SubResourceIntegrity".to_string(), r#"SRI requires a cross-origin policy, defaulting to "anonymous". -Set webpack option output.crossOriginLoading to a value other than false +Set rspack option output.crossOriginLoading to a value other than false to make this warning go away. See https://w3c.github.io/webappsec-subresource-integrity/#cross-origin-data-leakage"# .to_string(), @@ -84,54 +87,53 @@ See https://w3c.github.io/webappsec-subresource-integrity/#cross-origin-data-lea let mut should_warn_content_hash = false; for result in results { + for warning in result.warnings { + compilation.push_diagnostic(Diagnostic::warn( + "SubResourceIntegrity".to_string(), + warning, + )); + } + + let Some(integrity) = result.integrity else { + continue; + }; + + integrities.insert(result.file.clone(), integrity.clone()); + if let Some(placeholder) = result.placeholder { + hash_by_placeholders.insert(placeholder, integrity.clone()); + } + + let real_content_hash = compilation.options.optimization.real_content_hash; + if let Some(source) = result.source { if let Some(error) = compilation - .update_asset(&result.file, |_, info| Ok((Arc::new(source), info))) + .update_asset(&result.file, |_, info| { + if use_any_hash(&info) && (info.content_hash.is_empty() || !real_content_hash) { + should_warn_content_hash = true; + } + + let mut new_info = info.clone(); + new_info.content_hash.insert(integrity); + Ok((Arc::new(source), new_info)) + }) .err() { compilation.push_diagnostic(Diagnostic::error( "SubResourceIntegrity".to_string(), format!("Failed to update asset '{}': {}", result.file, error), )); - } else { - let asset = compilation - .assets() - .get(&result.file) - .expect("should have asset"); - if use_any_hash(&asset.info) - && (asset.info.content_hash.is_empty() - || !compilation.options.optimization.real_content_hash) - { - should_warn_content_hash = true; - } } - - if should_warn_content_hash { - compilation.push_diagnostic(Diagnostic::warn( - "SubResourceIntegrity".to_string(), - r#"Using [hash], [fullhash], [modulehash], or [chunkhash] is dangerous + } + } + if should_warn_content_hash { + compilation.push_diagnostic(Diagnostic::warn( + "SubResourceIntegrity".to_string(), + r#"Using [hash], [fullhash], [modulehash], or [chunkhash] is dangerous with SRI. The same is true for [contenthash] when realContentHash is disabled. Use [contenthash] and ensure realContentHash is enabled. See the README for more information."# - .to_string(), - )); - } - } - - if let (Some(placeholder), Some(integrity)) = (result.placeholder, result.integrity.clone()) { - hash_by_placeholders.insert(placeholder, integrity); - } - - if let Some(integrity) = result.integrity { - integrities.insert(result.file, integrity); - } - - for warning in result.warnings { - compilation.push_diagnostic(Diagnostic::warn( - "SubResourceIntegrity".to_string(), - warning, - )); - } + .to_string(), + )); } } @@ -151,7 +153,7 @@ fn process_chunk_source( let mut warnings = vec![]; let source_content = source.source(); if source_content.contains("webpackHotUpdate") { - warnings.push("webpack-subresource-integrity may interfere with hot reloading. Consider disabling this plugin in development mode.".to_string()); + warnings.push("SubResourceIntegrity: SubResourceIntegrityPlugin may interfere with hot reloading. Consider disabling this plugin in development mode.".to_string()); } // replace placeholders with integrity hash @@ -261,6 +263,28 @@ pub async fn handle_assets(&self, compilation: &mut Compilation) -> Result<()> { Ok(()) } +#[plugin_hook(CompilationAfterProcessAssets for SRIPlugin)] +pub async fn detect_unresolved_integrity(&self, compilation: &mut Compilation) -> Result<()> { + let mut contain_unresolved_files = vec![]; + for chunk in compilation.chunk_by_ukey.values() { + for file in chunk.files() { + if let Some(source) = compilation.assets().get(file).and_then(|a| a.get_source()) { + if source.source().contains(PLACEHOLDER_PREFIX.as_str()) { + contain_unresolved_files.push(file.to_string()); + } + } + } + } + + for file in contain_unresolved_files { + compilation.push_diagnostic(Diagnostic::error( + "SubResourceIntegrity".to_string(), + format!("Asset {} contains unresolved integrity placeholders", file), + )); + } + Ok(()) +} + #[plugin_hook(RealContentHashPluginUpdateHash for SRIPlugin)] pub async fn update_hash( &self, diff --git a/crates/rspack_plugin_sri/src/lib.rs b/crates/rspack_plugin_sri/src/lib.rs index 1a12b9795726..2cbe2d32d62e 100644 --- a/crates/rspack_plugin_sri/src/lib.rs +++ b/crates/rspack_plugin_sri/src/lib.rs @@ -7,7 +7,7 @@ mod util; use std::sync::LazyLock; -use asset::{handle_assets, update_hash}; +use asset::{detect_unresolved_integrity, handle_assets, update_hash}; use config::SRICompilationContext; pub use config::{ IntegrityCallbackData, IntegrityCallbackFn, IntegrityHtmlPlugin, SRIPluginOptions, @@ -16,7 +16,7 @@ use html::{alter_asset_tag_groups, before_asset_tag_generation}; pub use integrity::SRIHashFunction; use rspack_core::{ ChunkLoading, ChunkLoadingType, Compilation, CompilationId, CompilationParams, - CompilerThisCompilation, Plugin, PluginContext, + CompilerThisCompilation, CrossOriginLoading, Plugin, PluginContext, }; use rspack_error::{Diagnostic, Result}; use rspack_hook::{plugin, plugin_hook}; @@ -106,6 +106,16 @@ async fn handle_compilation( .update_hash .tap(update_hash::new(self)); + if matches!( + compilation.options.output.cross_origin_loading, + CrossOriginLoading::Disable + ) { + compilation.push_diagnostic(Diagnostic::error( + "SubResourceIntegrity".to_string(), + "rspack option output.crossOriginLoading not set, code splitting will not work!".to_string(), + )); + } + let mut runtime_plugin_hooks = RuntimePlugin::get_compilation_hooks_mut(compilation.id()); runtime_plugin_hooks .create_script @@ -158,6 +168,12 @@ impl Plugin for SRIPlugin { .process_assets .tap(handle_assets::new(self)); + ctx + .context + .compilation_hooks + .after_process_assets + .tap(detect_unresolved_integrity::new(self)); + ctx .context .compiler_hooks diff --git a/crates/rspack_plugin_sri/src/runtime.rs b/crates/rspack_plugin_sri/src/runtime.rs index 564b01f7d0a7..c00d8256d3fe 100644 --- a/crates/rspack_plugin_sri/src/runtime.rs +++ b/crates/rspack_plugin_sri/src/runtime.rs @@ -18,23 +18,13 @@ use crate::{ SRIHashFunction, SRIPlugin, SRIPluginInner, }; -fn add_attribute( - tag: &str, - code: &str, - cross_origin_loading: &CrossOriginLoading, -) -> Result { - if matches!(cross_origin_loading, CrossOriginLoading::Disable) { - Err(error!( - "rspack option output.crossOriginLoading not set, code splitting will not work!" - )) - } else { - Ok(format!( - "{}\n{tag}.integrity = {}[chunkId];\n{tag}.crossOrigin = {};", - code, - SRI_HASH_VARIABLE_REFERENCE.as_str(), - cross_origin_loading - )) - } +fn add_attribute(tag: &str, code: &str, cross_origin_loading: &CrossOriginLoading) -> String { + format!( + "{}\n{tag}.integrity = {}[chunkId];\n{tag}.crossOrigin = {};", + code, + SRI_HASH_VARIABLE_REFERENCE.as_str(), + cross_origin_loading + ) } #[impl_runtime_module] @@ -128,14 +118,14 @@ fn generate_sri_hash_placeholders( #[plugin_hook(RuntimePluginCreateScript for SRIPlugin)] pub async fn create_script(&self, mut data: CreateScriptData) -> Result { let ctx = SRIPlugin::get_compilation_sri_context(data.chunk.compilation_id); - data.code = add_attribute("script", &data.code, &ctx.cross_origin_loading)?; + data.code = add_attribute("script", &data.code, &ctx.cross_origin_loading); Ok(data) } #[plugin_hook(RuntimePluginLinkPreload for SRIPlugin)] pub async fn link_preload(&self, mut data: LinkPreloadData) -> Result { let ctx = SRIPlugin::get_compilation_sri_context(data.chunk.compilation_id); - data.code = add_attribute("link", &data.code, &ctx.cross_origin_loading)?; + data.code = add_attribute("link", &data.code, &ctx.cross_origin_loading); Ok(data) } diff --git a/packages/rspack/src/builtin-plugin/SRIPlugin.ts b/packages/rspack/src/builtin-plugin/SRIPlugin.ts index c81c264a479e..bc096781c5e8 100644 --- a/packages/rspack/src/builtin-plugin/SRIPlugin.ts +++ b/packages/rspack/src/builtin-plugin/SRIPlugin.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join, relative, sep } from "node:path"; import { BuiltinPluginName, + JsRspackError, type RawIntegrityData, type RawSRIPluginOptions } from "@rspack/binding"; @@ -100,11 +101,26 @@ const NativeSRIPlugin = create( export class SRIPlugin extends NativeSRIPlugin { private integrities: Map = new Map(); private options: SRIPluginOptions; + private validateError: Error | null = null constructor(options: SRIPluginOptions) { - validateSRIPluginOptions(options); - const finalOptions = { + let validateError: Error | null = null; + if (typeof options !== "object") { + throw new Error("SubResourceIntegrity: argument must be an object"); + } + try { + validateSRIPluginOptions(options); + } catch (e) { + validateError = e as Error; + } + + const finalOptions = validateError ? { + hashFuncNames: ["sha384"], + htmlPlugin: NATIVE_HTML_PLUGIN, + enabled: false + } : { hashFuncNames: options.hashFuncNames ?? ["sha384"], - htmlPlugin: options.htmlPlugin ?? NATIVE_HTML_PLUGIN + htmlPlugin: options.htmlPlugin ?? NATIVE_HTML_PLUGIN, + enabled: options.enabled ?? "auto" }; super({ ...finalOptions, @@ -114,7 +130,8 @@ export class SRIPlugin extends NativeSRIPlugin { ); } }); - this.options = finalOptions; + this.validateError = validateError; + this.options = finalOptions as SRIPluginOptions; } private isEnabled(compiler: Compiler) { @@ -198,11 +215,37 @@ export class SRIPlugin extends NativeSRIPlugin { apply(compiler: Compiler): void { if (!this.isEnabled(compiler)) { + if (this.validateError) { + compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => { + compilation.errors.push(this.validateError as unknown as JsRspackError); + }); + } return; } super.apply(compiler); + compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => { + compilation.hooks.statsFactory.tap(PLUGIN_NAME, statsFactory => { + statsFactory.hooks.extract + .for("asset") + .tap(PLUGIN_NAME, (object, asset) => { + const contenthash = asset.info?.contenthash; + if (contenthash) { + const shaHashes = ( + Array.isArray(contenthash) ? contenthash : [contenthash] + ).filter((hash: unknown) => String(hash).match(/^sha[0-9]+-/)); + if (shaHashes.length > 0) { + (object as unknown as { + integrity: string; + }).integrity = + shaHashes.join(" "); + } + } + }); + }); + }); + if ( typeof this.options.htmlPlugin === "string" && this.options.htmlPlugin !== NATIVE_HTML_PLUGIN diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfbb60130b19..abe3d3f473d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -820,9 +820,9 @@ importers: '@swc/core': specifier: 1.10.1 version: 1.10.1(@swc/helpers@0.5.15) - '@swc/jest': - specifier: ^0.2.37 - version: 0.2.37(@swc/core@1.10.1(@swc/helpers@0.5.15)) + '@types/tmp': + specifier: ^0.2.3 + version: 0.2.6 css-loader: specifier: ^7.1.2 version: 7.1.2(@rspack/core@packages+rspack)(webpack@5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0))) @@ -835,6 +835,9 @@ importers: html-loader: specifier: 2.1.1 version: 2.1.1(webpack@5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0))) + html-webpack-plugin: + specifier: 5.6.3 + version: 5.6.3(@rspack/core@packages+rspack)(webpack@5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0))) jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -853,6 +856,12 @@ importers: sass-loader: specifier: ^16.0.0 version: 16.0.1(@rspack/core@packages+rspack)(sass-embedded@1.81.0)(sass@1.56.2)(webpack@5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0))) + tmp: + specifier: ^0.2.1 + version: 0.2.3 + tmp-promise: + specifier: ^3.0.3 + version: 3.0.3 tests/webpack-cli-test: dependencies: @@ -2187,10 +2196,6 @@ packages: node-notifier: optional: true - '@jest/create-cache-key-function@29.7.0': - resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3767,12 +3772,6 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/jest@0.2.37': - resolution: {integrity: sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==} - engines: {npm: '>= 7.0.0'} - peerDependencies: - '@swc/core': '*' - '@swc/plugin-remove-console@6.0.2': resolution: {integrity: sha512-T8x7f7HM4X8TsSe1LEHdQy2BAxFsyOyFIR+S0MeyyGFL03I2HpIBaoVWZW6+dhim2lPsWv/6nN5v1U3An1nMyQ==} @@ -4071,6 +4070,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -6318,6 +6320,18 @@ packages: webpack: optional: true + html-webpack-plugin@5.6.3: + resolution: {integrity: sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==} + engines: {node: '>=10.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} @@ -6974,9 +6988,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -9566,10 +9577,17 @@ packages: resolution: {integrity: sha512-q9GOap6q3KCsLMdOjXhWU5jVZ8/1dIib898JBRLsN+tBhENpBDcAVQbE0epADOjw11FhQQy9AcbqKGBQPUfTQA==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -11441,10 +11459,6 @@ snapshots: - supports-color - ts-node - '@jest/create-cache-key-function@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -13206,13 +13220,6 @@ snapshots: dependencies: tslib: 2.8.0 - '@swc/jest@0.2.37(@swc/core@1.10.1(@swc/helpers@0.5.15))': - dependencies: - '@jest/create-cache-key-function': 29.7.0 - '@swc/core': 1.10.1(@swc/helpers@0.5.15) - '@swc/counter': 0.1.3 - jsonc-parser: 3.3.1 - '@swc/plugin-remove-console@6.0.2': dependencies: '@swc/counter': 0.1.3 @@ -13569,6 +13576,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tmp@0.2.6': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -16180,6 +16189,17 @@ snapshots: '@rspack/core': link:packages/rspack webpack: 5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0)) + html-webpack-plugin@5.6.3(@rspack/core@packages+rspack)(webpack@5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0))): + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.21 + pretty-error: 4.0.0 + tapable: 2.2.1 + optionalDependencies: + '@rspack/core': link:packages/rspack + webpack: 5.95.0(@swc/core@1.10.1(@swc/helpers@0.5.15))(webpack-cli@5.1.4(webpack@5.95.0)) + htmlparser2@6.1.0: dependencies: domelementtype: 2.3.0 @@ -17046,8 +17066,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.3.1: {} - jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -20088,10 +20106,16 @@ snapshots: dependencies: tldts-core: 6.1.50 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.3 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 + tmp@0.2.3: {} + tmpl@1.0.5: {} to-fast-properties@1.0.3: {} diff --git a/tests/plugin-test/jest.config.js b/tests/plugin-test/jest.config.js index fffb92266ebf..d0d20fed1398 100644 --- a/tests/plugin-test/jest.config.js +++ b/tests/plugin-test/jest.config.js @@ -1,22 +1,20 @@ const path = require("path"); - - const root = path.resolve(__dirname, "../"); /** @type {import('jest').Config} */ const config = { + preset: "ts-jest", testEnvironment: "../../scripts/test/patch-node-env.cjs", testMatch: [ "/**/*.test.js", "/**/*.test.ts" ], transform: { - '^.+\\.ts?$': '@swc/jest', - }, + "^.+\\.(ts)?$": ["ts-jest", { tsconfig: "/tsconfig.json" }] + }, testTimeout: process.env.CI ? 60000 : 30000, prettierPath: require.resolve("prettier-2"), cache: false, - transformIgnorePatterns: [root], setupFilesAfterEnv: ["/setupTestEnv.js"], snapshotFormat: { escapeString: true, @@ -26,6 +24,7 @@ const config = { updateSnapshot: process.argv.includes("-u") || process.argv.includes("--updateSnapshot") }, + extensionsToTreatAsEsm: [".mts"] }; module.exports = config; diff --git a/tests/plugin-test/package.json b/tests/plugin-test/package.json index 3af263ddae6c..e616b26a5f3a 100644 --- a/tests/plugin-test/package.json +++ b/tests/plugin-test/package.json @@ -12,17 +12,20 @@ "devDependencies": { "@rspack/core": "workspace:*", "@swc/core": "1.10.1", - "@swc/jest": "^0.2.37", + "@types/tmp": "^0.2.3", "css-loader": "^7.1.2", "file-loader": "^6.2.0", "html-loader": "2.1.1", + "html-webpack-plugin": "5.6.3", "pug-loader": "2.4.0", "sass-embedded": "^1.77.8", "sass-loader": "^16.0.0", "jsdom": "^25.0.0", "del": "^6.0.0", "lodash": "^4.17.21", - "memfs": "4.8.1" + "memfs": "4.8.1", + "tmp": "^0.2.1", + "tmp-promise": "^3.0.3" }, "dependencies": {} } \ No newline at end of file diff --git a/tests/plugin-test/sri-plugin/README.md b/tests/plugin-test/sri-plugin/README.md new file mode 100644 index 000000000000..3a441039601d --- /dev/null +++ b/tests/plugin-test/sri-plugin/README.md @@ -0,0 +1,9 @@ +/** + * The test code is modified based on + * https://github.com/waysact/webpack-subresource-integrity/tree/main/webpack-subresource-integrity/src/__tests__ + * + * MIT Licensed + * Author Julian Scheid @jscheid + * Copyright (c) 2015-present Waysact Pty Ltd + * https://github.com/waysact/webpack-subresource-integrity/blob/main/LICENSE + */ \ No newline at end of file diff --git a/tests/plugin-test/sri-plugin/__fixtures__/simple-project/src/index.js b/tests/plugin-test/sri-plugin/__fixtures__/simple-project/src/index.js new file mode 100644 index 000000000000..eb09b08827ef --- /dev/null +++ b/tests/plugin-test/sri-plugin/__fixtures__/simple-project/src/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.log("ok"); diff --git a/tests/plugin-test/sri-plugin/__fixtures__/unresolved/src/index.js b/tests/plugin-test/sri-plugin/__fixtures__/unresolved/src/index.js new file mode 100644 index 000000000000..827aeb96866d --- /dev/null +++ b/tests/plugin-test/sri-plugin/__fixtures__/unresolved/src/index.js @@ -0,0 +1 @@ +console.log("*-*-*-CHUNK-SRI-HASH-foo"); diff --git a/tests/plugin-test/sri-plugin/__mocks__/html-webpack-plugin.js b/tests/plugin-test/sri-plugin/__mocks__/html-webpack-plugin.js new file mode 100644 index 000000000000..402ad50121f8 --- /dev/null +++ b/tests/plugin-test/sri-plugin/__mocks__/html-webpack-plugin.js @@ -0,0 +1,5 @@ +module.exports = { + get getHooks() { + throw new Error("bogus hwp accessed"); + }, +}; diff --git a/tests/plugin-test/sri-plugin/hwp-error.test.ts b/tests/plugin-test/sri-plugin/hwp-error.test.ts new file mode 100644 index 000000000000..62c9ad2d49cc --- /dev/null +++ b/tests/plugin-test/sri-plugin/hwp-error.test.ts @@ -0,0 +1,28 @@ +/** + * 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 { resolve } from "path"; +import { experiments } from "@rspack/core"; +import { runRspack } from "./test-utils"; + +const { SubresourceIntegrityPlugin } = experiments; + +jest.mock("html-webpack-plugin"); + +describe("sri-plugin/hwp-error", () => { + test("error when loading html-webpack-plugin", async () => { + await expect( + runRspack({ + entry: resolve(__dirname, "./__fixtures__/simple-project/src/"), + plugins: [new SubresourceIntegrityPlugin({ + htmlPlugin: "html-webpack-plugin" + })], + }) + ).rejects.toThrow("bogus hwp accessed"); + }); +}); + diff --git a/tests/plugin-test/sri-plugin/integration.test.ts b/tests/plugin-test/sri-plugin/integration.test.ts new file mode 100644 index 000000000000..846e79c32a03 --- /dev/null +++ b/tests/plugin-test/sri-plugin/integration.test.ts @@ -0,0 +1,94 @@ +/** + * 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 { + Stats, + StatsAsset, + RspackOptionsNormalized, +} from "@rspack/core"; +import { resolve } from "path"; +import tmp from "tmp-promise"; +import { experiments } from "@rspack/core"; +import { runRspack } from "./test-utils"; +import merge from "lodash/merge"; +const { SubresourceIntegrityPlugin } = experiments; + +jest.unmock("html-webpack-plugin"); + +async function runRspackForSimpleProject( + options: Partial = {} +): Promise { + const tmpDir = await tmp.dir({ unsafeCleanup: true }); + return await runRspack( + merge( + { + mode: "production", + output: { path: tmpDir.path, crossOriginLoading: "anonymous" }, + entry: resolve(__dirname, "./__fixtures__/simple-project/src/."), + plugins: [new SubresourceIntegrityPlugin({ + htmlPlugin: require.resolve("html-webpack-plugin"), + })], + }, + options + ) + ); +} + +describe("sri-plugin/integration", () => { + test("enabled with webpack mode=production", async () => { + const mainAsset = (await runRspackForSimpleProject()) + .toJson() + .assets?.find((asset: StatsAsset) => asset.name === "main.js"); + expect(mainAsset).toBeDefined(); + expect(mainAsset?.["integrity"]).toMatch(/^sha384-\S+$/); + }); + + test("disabled with webpack mode=development", async () => { + const mainAsset = (await runRspackForSimpleProject({ mode: "development" })) + .toJson() + .assets?.find((asset: StatsAsset) => asset.name === "main.js"); + expect(mainAsset).toBeDefined(); + expect(mainAsset?.["integrity"]).toBeUndefined(); + }); + + const isHashWarning = (warning: Error) => + warning.message.match(/Use \[contenthash\] and ensure realContentHash/); + + test("warns when [fullhash] is used", async () => { + const stats = await runRspackForSimpleProject({ + output: { filename: "[fullhash].js" }, + }); + + expect(stats.compilation.warnings.find(isHashWarning)).toBeDefined(); + }); + + test("warns when [contenthash] is used without realContentHash", async () => { + const stats = await runRspackForSimpleProject({ + output: { filename: "[contenthash].js" }, + optimization: { realContentHash: false }, + }); + + expect(stats.compilation.warnings.find(isHashWarning)).toBeDefined(); + }); + + test("doesn't warn when [contenthash] is used with realContentHash", async () => { + const stats = await runRspackForSimpleProject({ + output: { filename: "[contenthash].js" }, + optimization: { realContentHash: true }, + }); + + expect(stats.compilation.warnings).toHaveLength(0); + }); + + test("doesn't warn with default options", async () => { + const stats = await runRspackForSimpleProject(); + + expect(stats.compilation.warnings).toHaveLength(0); + }); +}); + + diff --git a/tests/plugin-test/sri-plugin/test-utils.ts b/tests/plugin-test/sri-plugin/test-utils.ts new file mode 100644 index 000000000000..4631862baa33 --- /dev/null +++ b/tests/plugin-test/sri-plugin/test-utils.ts @@ -0,0 +1,24 @@ +import rspack, { Configuration, Stats, StatsError } from "@rspack/core"; + +const errorFromStats = (stats: Stats | undefined): Error => { + const errors = stats?.toJson()?.errors; + if (!errors) { + return new Error("No stats"); + } + return new Error( + "Error:" + errors.map((error: StatsError) => error.message).join(", ") + ); +}; + +export const runRspack = (options: Configuration): Promise => + new Promise((resolve, reject) => { + rspack(options, (err: Error | null, stats: Stats | undefined) => { + if (err) { + reject(err); + } else if (stats?.hasErrors() === false) { + resolve(stats); + } else { + reject(errorFromStats(stats)); + } + }); + }); diff --git a/tests/plugin-test/sri-plugin/unit.test.ts b/tests/plugin-test/sri-plugin/unit.test.ts new file mode 100644 index 000000000000..44547b3a6694 --- /dev/null +++ b/tests/plugin-test/sri-plugin/unit.test.ts @@ -0,0 +1,344 @@ +/** + * 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 { resolve } from "path"; +import rspack, { Compiler, Compilation, Configuration } from "@rspack/core"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import { experiments } from "@rspack/core"; + +const { SubresourceIntegrityPlugin } = experiments; + +jest.unmock("html-webpack-plugin"); + +describe("sri-plugin/unit", () => { + function assert(value: unknown, message: string): asserts value { + if (!value) { + throw new Error(message); + } + } + + process.on("unhandledRejection", (error) => { + console.log(error); // eslint-disable-line no-console + process.exit(1); + }); + + test("throws an error when options is not an object", async () => { + expect(() => { + new SubresourceIntegrityPlugin(function dummy() { + // dummy function, never called + } as any); // eslint-disable-line no-new + }).toThrow( + /argument must be an object/ + ); + }); + + const runCompilation = (compiler: Compiler) => + new Promise((resolve, reject) => { + compiler.run((err, stats) => { + if (err) { + reject(err); + } else if (!stats) { + reject(new Error("Missing stats")); + } else { + resolve(stats.compilation); + } + }); + }); + + const disableOutputPlugin = { + apply(compiler: Compiler) { + compiler.hooks.compilation.tap( + "DisableOutputWebpackPlugin", + (compilation: Compilation) => { + compilation.hooks.afterProcessAssets.tap( + { + name: "DisableOutputWebpackPlugin", + stage: 10000, + }, + (compilationAssets) => { + Object.keys(compilation.assets).forEach((asset) => { + delete compilation.assets[asset]; + }); + Object.keys(compilationAssets).forEach((asset) => { + delete compilationAssets[asset]; + }); + } + ); + } + ); + }, + }; + + const defaultOptions: Partial = { + mode: "none", + entry: resolve(__dirname, "./__fixtures__/simple-project/src/."), + output: { + crossOriginLoading: "anonymous", + }, + }; + + // CHANGED: throw error when not standard hash function because it can not be supported by rust + // test("warns when no standard hash function name is specified", async () => { + test("throw error when not standard hash function name is specified", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: ["md5" as any], + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin], + }) + ); + + // expect(compilation.errors).toEqual([]); + // expect(compilation.warnings[0]?.message).toMatch( + // new RegExp( + // "It is recommended that at least one hash function is part of " + + // "the set for which support is mandated by the specification" + // ) + // ); + // expect(compilation.warnings[1]).toBeUndefined(); + expect(compilation.warnings.length).toEqual(0); + expect(compilation.errors[0]?.message).toMatch( + /Expected 'sha256' \| 'sha384' \| 'sha512'/ + ); + expect(compilation.warnings[1]).toBeUndefined(); + }); + + test("supports new constructor with array of hash function names", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: ["sha256", "sha384"], + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(0); + expect(compilation.warnings.length).toBe(0); + }); + + test("errors if hash function names is not an array", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: "sha256" as any, + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(0); + expect(compilation.errors[0]?.message).toMatch( + /Expected array, received string/ + ); + }); + + test("errors if hash function names contains non-string", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: [1234] as any, + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(0); + expect(compilation.errors[0]?.message).toMatch( + /Expected 'sha256' \| 'sha384' \| 'sha512', received number/ + ); + }); + + test("errors if hash function names are empty", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: [] as any, + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(0); + expect(compilation.errors[0]?.message).toMatch( + /Array must contain at least 1 element/ + ); + }); + + test("errors if hash function names contains unsupported digest", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: ["frobnicate"] as any, + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(0); + expect(compilation.errors[0]?.message).toMatch( + /Expected 'sha256' \| 'sha384' \| 'sha512', received 'frobnicate'/ + ); + }); + + // TODO: support hashLoading option + // test("errors if hashLoading option uses unknown value", async () => { + // const plugin = new SubresourceIntegrityPlugin({ + // hashLoading: + // "invalid" as unknown as SubresourceIntegrityPluginOptions["hashLoading"], + // }); + + // const compilation = await runCompilation( + // rspack({ + // ...defaultOptions, + // plugins: [plugin, disableOutputPlugin], + // }) + // ); + + // expect(compilation.errors.length).toBe(1); + // expect(compilation.warnings.length).toBe(0); + // expect(compilation.errors[0]?.message).toMatch( + // /options.hashLoading must be one of 'eager', 'lazy', instead got 'invalid'/ + // ); + // }); + + test("uses default options", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: ["sha256"], + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(plugin["options"].hashFuncNames).toEqual(["sha256"]); + expect(plugin["options"].enabled).toBeTruthy(); + expect(compilation.errors.length).toBe(0); + expect(compilation.warnings.length).toBe(0); + }); + + test("should warn when output.crossOriginLoading is not set", async () => { + const plugin = new SubresourceIntegrityPlugin({ hashFuncNames: ["sha256"] }); + + // CHANGED: not support main template hooks, use runtime hooks instead + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + output: { crossOriginLoading: false }, + plugins: [plugin, disableOutputPlugin, { + apply(compiler: Compiler) { + const { RuntimeGlobals } = compiler.webpack; + compiler.hooks.compilation.tap("test", (compilation) => { + compilation.hooks.additionalTreeRuntimeRequirements.tap("test", (chunk, set) => { + set.add(RuntimeGlobals.loadScript); + set.add(RuntimeGlobals.ensureChunkHandlers); + set.add(RuntimeGlobals.preloadChunkHandlers); + set.add(RuntimeGlobals.preloadChunk); + }); + }); + }, + }], + }) + ); + + // compilation.mainTemplate.hooks.jsonpScript.call("", {} as unknown as Chunk); + // compilation.mainTemplate.hooks.linkPreload.call("", {} as unknown as Chunk); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(1); + expect(compilation.warnings[0]?.message).toMatch( + /Set rspack option output\.crossOriginLoading/ + ); + expect(compilation.errors[0]?.message).toMatch( + /rspack option output.crossOriginLoading not set, code splitting will not work!/ + ); + }); + + test("should ignore tags without attributes", async () => { + const plugin = new SubresourceIntegrityPlugin({ hashFuncNames: ["sha256"] }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + plugins: [plugin, disableOutputPlugin], + }) + ); + + const tag = { + tagName: "script", + voidTag: false, + attributes: {}, + meta: {}, + }; + + HtmlWebpackPlugin.getHooks( + compilation as any + ).alterAssetTagGroups.promise({ + headTags: [], + bodyTags: [tag], + outputName: "foo", + publicPath: "public", + plugin: new HtmlWebpackPlugin(), + }); + + expect(Object.keys(tag.attributes)).not.toContain(["integrity"]); + expect(compilation.errors.length).toEqual(0); + expect(compilation.warnings.length).toEqual(0); + }); + + test("positive assertion", () => { + assert(true, "Pass"); + }); + + test("negative assertion", () => { + expect(() => { + assert(false, "Fail"); + }).toThrow(new Error("Fail")); + }); + + test("errors with unresolved integrity", async () => { + const plugin = new SubresourceIntegrityPlugin({ + hashFuncNames: ["sha256", "sha384"], + }); + + const compilation = await runCompilation( + rspack({ + ...defaultOptions, + entry: resolve(__dirname, "./__fixtures__/unresolved/src/."), + plugins: [plugin, disableOutputPlugin], + }) + ); + + expect(compilation.errors.length).toBe(1); + expect(compilation.warnings.length).toBe(0); + + expect(compilation.errors[0]?.message).toMatch( + new RegExp("contains unresolved integrity placeholders") + ); + }); +}); + diff --git a/tests/plugin-test/tsconfig.json b/tests/plugin-test/tsconfig.json new file mode 100644 index 000000000000..4027dfe69f16 --- /dev/null +++ b/tests/plugin-test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "strict": false, + "checkJs": false, + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "." + ], + "references": [], + "ts-node": { + "transpileOnly": true + } +} \ No newline at end of file