From e8dd767cde8fcd539a8c5135816f1266ba909c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 9 Jul 2025 18:30:50 +0000 Subject: [PATCH 1/6] Add support to specify the integrity hash of a asset when pinning --- lib/importmap/map.rb | 52 ++++++++++++++++++++++-------- test/dummy/config/importmap.rb | 1 + test/importmap_tags_helper_test.rb | 26 ++++++++++++--- test/importmap_test.rb | 22 +++++++++++-- test/reloader_test.rb | 2 +- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb index 5d0cd62c..022cc8b0 100644 --- a/lib/importmap/map.rb +++ b/lib/importmap/map.rb @@ -25,9 +25,9 @@ def draw(path = nil, &block) self end - def pin(name, to: nil, preload: true) + def pin(name, to: nil, preload: true, integrity: nil) clear_cache - @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload) + @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity) end def pin_all_from(dir, under: nil, to: nil, preload: true) @@ -53,7 +53,9 @@ def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :pr # `cache_key` to vary the cache used by this method for the different cases. def to_json(resolver:, cache_key: :json) cache_as(cache_key) do - JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) }) + packages = expanded_packages_and_directories + map = build_import_map(packages, resolver: resolver) + JSON.pretty_generate(map) end end @@ -85,7 +87,7 @@ def cache_sweeper(watches: nil) private MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true) - MappedFile = Struct.new(:name, :path, :preload, keyword_init: true) + MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true) def cache_as(name) if result = @cache[name.to_s] @@ -105,19 +107,41 @@ def rescuable_asset_error?(error) def resolve_asset_paths(paths, resolver:) paths.transform_values do |mapping| - begin - resolver.path_to_asset(mapping.path) - rescue => e - if rescuable_asset_error?(e) - Rails.logger.warn "Importmap skipped missing path: #{mapping.path}" - nil - else - raise e - end - end + resolve_asset_path(mapping.path, resolver:) end.compact end + def resolve_asset_path(path, resolver:) + begin + resolver.path_to_asset(path) + rescue => e + if rescuable_asset_error?(e) + Rails.logger.warn "Importmap skipped missing path: #{path}" + nil + else + raise e + end + end + end + + def build_import_map(packages, resolver:) + map = { "imports" => resolve_asset_paths(packages, resolver: resolver) } + integrity = build_integrity_hash(packages, resolver: resolver) + map["integrity"] = integrity unless integrity.empty? + map + end + + def build_integrity_hash(packages, resolver:) + packages.filter_map do |name, mapping| + next unless mapping.integrity + + resolved_path = resolve_asset_path(mapping.path, resolver: resolver) + next unless resolved_path + + [resolved_path, mapping.integrity] + end.to_h + end + def expanded_preloading_packages_and_directories(entry_point:) expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? } end diff --git a/test/dummy/config/importmap.rb b/test/dummy/config/importmap.rb index 850ca726..2fece223 100644 --- a/test/dummy/config/importmap.rb +++ b/test/dummy/config/importmap.rb @@ -2,3 +2,4 @@ pin "md5", to: "https://cdn.skypack.dev/md5", preload: true pin "not_there", to: "nowhere.js", preload: false +pin "rich_text", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" diff --git a/test/importmap_tags_helper_test.rb b/test/importmap_tags_helper_test.rb index 6796aa9c..ec9aa435 100644 --- a/test/importmap_tags_helper_test.rb +++ b/test/importmap_tags_helper_test.rb @@ -20,15 +20,33 @@ def content_security_policy_nonce end test "javascript_inline_importmap_tag" do - assert_match \ - %r{}, + assert_dom_equal( + %( + + ), javascript_inline_importmap_tag + ) end test "javascript_importmap_module_preload_tags" do - assert_dom_equal \ - %(), + assert_dom_equal( + %( + + + ), javascript_importmap_module_preload_tags + ) end test "tags have no nonce if CSP is not configured" do diff --git a/test/importmap_test.rb b/test/importmap_test.rb index bfb60b3a..1632d2e6 100644 --- a/test/importmap_test.rb +++ b/test/importmap_test.rb @@ -5,8 +5,8 @@ def setup @importmap = Importmap::Map.new.tap do |map| map.draw do pin "application", preload: false - pin "editor", to: "rich_text.js", preload: false - pin "not_there", to: "nowhere.js", preload: false + pin "editor", to: "rich_text.js", preload: false, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + pin "not_there", to: "nowhere.js", preload: false, integrity: "sha384-somefakehash" pin "md5", to: "https://cdn.skypack.dev/md5", preload: true pin "leaflet", to: "https://cdn.skypack.dev/leaflet", preload: 'application' pin "chartkick", to: "https://cdn.skypack.dev/chartkick", preload: ['application', 'alternate'] @@ -30,6 +30,22 @@ def setup assert_match %r|assets/rich_text-.*\.js|, generate_importmap_json["imports"]["editor"] end + test "local pin with integrity" do + editor_path = generate_importmap_json["imports"]["editor"] + assert_match %r|assets/rich_text-.*\.js|, editor_path + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", generate_importmap_json["integrity"][editor_path] + assert_nil generate_importmap_json["imports"]["not_there"] + assert_not_includes generate_importmap_json["integrity"].values, "sha384-somefakehash" + end + + test "integrity is not present if there is no integrity set in the map" do + @importmap = Importmap::Map.new.tap do |map| + map.pin "application", preload: false + end + + assert_not generate_importmap_json.key?("integrity") + end + test "local pin missing is removed from generated importmap" do assert_nil generate_importmap_json["imports"]["not_there"] end @@ -138,6 +154,6 @@ def setup private def generate_importmap_json - JSON.parse @importmap.to_json(resolver: ApplicationController.helpers) + @generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers) end end diff --git a/test/reloader_test.rb b/test/reloader_test.rb index e366b922..60de5f43 100644 --- a/test/reloader_test.rb +++ b/test/reloader_test.rb @@ -16,7 +16,7 @@ class ReloaderTest < ActiveSupport::TestCase Rails.application.importmap = Importmap::Map.new.draw { pin "md5", to: "https://cdn.skypack.dev/md5" } assert_not_predicate @reloader, :updated? - assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there ] do + assert_changes -> { Rails.application.importmap.packages.keys }, from: %w[ md5 ], to: %w[ md5 not_there rich_text ] do touch_config assert @reloader.execute_if_updated end From 0c14dd6dc2bdf0de291dd3329237d9e3d52ef3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 9 Jul 2025 18:44:13 +0000 Subject: [PATCH 2/6] Fix warnings of ambiguous `/` --- test/importmap_tags_helper_test.rb | 8 +++---- test/importmap_test.rb | 36 +++++++++++++++--------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/test/importmap_tags_helper_test.rb b/test/importmap_tags_helper_test.rb index ec9aa435..c9a09df4 100644 --- a/test/importmap_tags_helper_test.rb +++ b/test/importmap_tags_helper_test.rb @@ -52,7 +52,7 @@ def content_security_policy_nonce test "tags have no nonce if CSP is not configured" do @request = FakeRequest.new - assert_no_match /nonce/, javascript_importmap_tags("application") + assert_no_match(/nonce/, javascript_importmap_tags("application")) ensure @request = nil end @@ -60,9 +60,9 @@ def content_security_policy_nonce test "tags have nonce if CSP is configured" do @request = FakeRequest.new("iyhD0Yc0W+c=") - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application") - assert_match /nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_inline_importmap_tag) + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_import_module_tag("application")) + assert_match(/nonce="iyhD0Yc0W\+c="/, javascript_importmap_module_preload_tags) ensure @request = nil end diff --git a/test/importmap_test.rb b/test/importmap_test.rb index 1632d2e6..bb5e9196 100644 --- a/test/importmap_test.rb +++ b/test/importmap_test.rb @@ -99,35 +99,35 @@ def setup test "preloaded modules are included in preload tags when no entry_point specified" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers).to_s - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_match /leaflet/, preloading_module_paths - assert_no_match /application/, preloading_module_paths - assert_no_match /tinymce/, preloading_module_paths + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_match(/leaflet/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) + assert_no_match(/tinymce/, preloading_module_paths) end test "preloaded modules are included in preload tags based on single entry_point provided" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: "alternate").to_s - assert_no_match /leaflet/, preloading_module_paths - assert_match /tinymce/, preloading_module_paths - assert_match /chartkick/, preloading_module_paths - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_no_match /application/, preloading_module_paths + assert_no_match(/leaflet/, preloading_module_paths) + assert_match(/tinymce/, preloading_module_paths) + assert_match(/chartkick/, preloading_module_paths) + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) end test "preloaded modules are included in preload tags based on multiple entry_points provided" do preloading_module_paths = @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"]).to_s - assert_match /leaflet/, preloading_module_paths - assert_match /tinymce/, preloading_module_paths - assert_match /chartkick/, preloading_module_paths - assert_match /md5/, preloading_module_paths - assert_match /goodbye_controller/, preloading_module_paths - assert_no_match /application/, preloading_module_paths + assert_match(/leaflet/, preloading_module_paths) + assert_match(/tinymce/, preloading_module_paths) + assert_match(/chartkick/, preloading_module_paths) + assert_match(/md5/, preloading_module_paths) + assert_match(/goodbye_controller/, preloading_module_paths) + assert_no_match(/application/, preloading_module_paths) end test "digest" do - assert_match /^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers) + assert_match(/^\w{40}$/, @importmap.digest(resolver: ApplicationController.helpers)) end test "separate caches" do From 1a48c83d7c3aa845781f94db71629c40487ea59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 9 Jul 2025 19:43:08 +0000 Subject: [PATCH 3/6] Expose integrity in the module preload tags --- .../importmap/importmap_tags_helper.rb | 18 ++- lib/importmap/map.rb | 54 +++++++- test/importmap_tags_helper_test.rb | 2 +- test/importmap_test.rb | 127 ++++++++++++++++++ 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/app/helpers/importmap/importmap_tags_helper.rb b/app/helpers/importmap/importmap_tags_helper.rb index fee48a3a..295b6dc9 100644 --- a/app/helpers/importmap/importmap_tags_helper.rb +++ b/app/helpers/importmap/importmap_tags_helper.rb @@ -25,13 +25,23 @@ def javascript_import_module_tag(*module_names) # (defaults to Rails.application.importmap), such that they'll be fetched # in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload). def javascript_importmap_module_preload_tags(importmap = Rails.application.importmap, entry_point: "application") - javascript_module_preload_tag(*importmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point)) + packages = importmap.preloaded_module_packages(resolver: self, entry_point:, cache_key: entry_point) + + _generate_preload_tags(packages) { |path, package| [path, { integrity: package.integrity }] } end # Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element. def javascript_module_preload_tag(*paths) - safe_join(Array(paths).collect { |path| - tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce - }, "\n") + _generate_preload_tags(paths) { |path| [path, {}] } end + + private + def _generate_preload_tags(items) + content_security_policy_nonce = request&.content_security_policy_nonce + + safe_join(Array(items).collect { |item| + path, options = yield(item) + tag.link rel: "modulepreload", href: path, nonce: content_security_policy_nonce, **options + }, "\n") + end end diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb index 022cc8b0..24344460 100644 --- a/lib/importmap/map.rb +++ b/lib/importmap/map.rb @@ -41,8 +41,60 @@ def pin_all_from(dir, under: nil, to: nil, preload: true) # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for # the different cases. def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths) + preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys + end + + # Returns a hash of resolved module paths to their corresponding package objects for all pinned packages + # that are marked for preloading. The hash keys are the resolved asset paths, and the values are the + # +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity. + # + # The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or + # +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the + # +asset_host+ you want these resolved paths to use. + # + # ==== Parameters + # + # [+resolver+] + # An object that responds to +path_to_asset+ for resolving asset paths. + # + # [+entry_point+] + # The entry point name or array of entry point names to determine which packages should be preloaded. + # Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point. + # Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry + # point is specified. + # + # [+cache_key+] + # A custom cache key to vary the cache used by this method for different cases, such as resolving + # for different asset hosts. Defaults to +:preloaded_module_packages+. + # + # ==== Returns + # + # A hash where: + # * Keys are resolved asset paths (strings) + # * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes + # + # Missing assets are gracefully handled and excluded from the returned hash. + # + # ==== Examples + # + # # Get all preloaded packages for the default "application" entry point + # packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + # # => { "/assets/application-abc123.js" => #, + # # "https://cdn.skypack.dev/react" => # } + # + # # Get preloaded packages for a specific entry point + # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin") + # + # # Get preloaded packages for multiple entry points + # packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"]) + # + # # Use a custom cache key for different asset hosts + # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host") + def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages) cache_as(cache_key) do - resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values + expanded_preloading_packages_and_directories(entry_point:).to_h do |_, package| + [resolve_asset_path(package.path, resolver: resolver), package] + end.delete_if { |key| key.nil? } end end diff --git a/test/importmap_tags_helper_test.rb b/test/importmap_tags_helper_test.rb index c9a09df4..21a8ef97 100644 --- a/test/importmap_tags_helper_test.rb +++ b/test/importmap_tags_helper_test.rb @@ -43,7 +43,7 @@ def content_security_policy_nonce assert_dom_equal( %( - + ), javascript_importmap_module_preload_tags ) diff --git a/test/importmap_test.rb b/test/importmap_test.rb index bb5e9196..0e880792 100644 --- a/test/importmap_test.rb +++ b/test/importmap_test.rb @@ -152,6 +152,133 @@ def setup assert_not_equal set_two, @importmap.preloaded_module_paths(resolver: ApplicationController.helpers, cache_key: "2").to_s end + test "preloaded_module_packages returns hash of resolved paths to packages when no entry_point specified" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package" + assert_equal "md5", md5.name + assert_equal "https://cdn.skypack.dev/md5", md5.path + assert_equal true, md5.preload + + goodbye_controller_path = packages.keys.find { |path| path.include?("goodbye_controller") } + assert goodbye_controller_path, "Should include goodbye_controller package" + assert_equal "controllers/goodbye_controller", packages[goodbye_controller_path].name + assert_equal true, packages[goodbye_controller_path].preload + + leaflet = packages["https://cdn.skypack.dev/leaflet"] + assert leaflet, "Should include leaflet package" + assert_equal "leaflet", leaflet.name + assert_equal "https://cdn.skypack.dev/leaflet", leaflet.path + assert_equal 'application', leaflet.preload + + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package" + assert_equal "chartkick", chartkick.name + assert_equal ['application', 'alternate'], chartkick.preload + + application_path = packages.keys.find { |path| path.include?("application") } + assert_nil application_path, "Should not include application package (preload: false)" + + tinymce_path = packages.keys.find { |path| path.include?("tinymce") } + assert_nil tinymce_path, "Should not include tinymce package (preload: 'alternate')" + end + + test "preloaded_module_packages returns hash based on single entry_point provided" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: "alternate") + + tinymce = packages["https://cdn.skypack.dev/tinymce"] + assert tinymce, "Should include tinymce package for alternate entry point" + assert_equal "tinyMCE", tinymce.name + assert_equal "https://cdn.skypack.dev/tinymce", tinymce.path + assert_equal 'alternate', tinymce.preload + + # Should include packages for multiple entry points (chartkick preloads for both 'application' and 'alternate') + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package" + assert_equal "chartkick", chartkick.name + assert_equal ['application', 'alternate'], chartkick.preload + + # Should include always-preloaded packages + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package (always preloaded)" + + leaflet_path = packages.keys.find { |path| path.include?("leaflet") } + assert_nil leaflet_path, "Should not include leaflet package (preload: 'application' only)" + end + + test "preloaded_module_packages returns hash based on multiple entry_points provided" do + packages = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, entry_point: ["application", "alternate"]) + + leaflet = packages["https://cdn.skypack.dev/leaflet"] + assert leaflet, "Should include leaflet package for application entry point" + + # Should include packages for 'alternate' entry point + tinymce = packages["https://cdn.skypack.dev/tinymce"] + assert tinymce, "Should include tinymce package for alternate entry point" + + # Should include packages for multiple entry points + chartkick = packages["https://cdn.skypack.dev/chartkick"] + assert chartkick, "Should include chartkick package for both entry points" + + # Should include always-preloaded packages + md5 = packages["https://cdn.skypack.dev/md5"] + assert md5, "Should include md5 package (always preloaded)" + + application_path = packages.keys.find { |path| path.include?("application") } + assert_nil application_path, "Should not include application package (preload: false)" + end + + test "preloaded_module_packages includes package integrity when present" do + # Create a new importmap with a preloaded package that has integrity + importmap = Importmap::Map.new.tap do |map| + map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + editor_path = packages.keys.find { |path| path.include?("rich_text") } + assert editor_path, "Should include editor package" + assert_equal "editor", packages[editor_path].name + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity + end + + test "preloaded_module_packages uses custom cache_key" do + set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + + ActionController::Base.asset_host = "http://assets.example.com" + + set_two = @importmap.preloaded_module_packages(resolver: ActionController::Base.helpers, cache_key: "2").to_s + + assert_not_equal set_one, set_two + ensure + ActionController::Base.asset_host = nil + end + + test "preloaded_module_packages caches reset" do + set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + set_two = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s + + @importmap.pin "something", to: "https://cdn.example.com/somewhere.js", preload: true + + assert_not_equal set_one, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s + assert_not_equal set_two, @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "2").to_s + end + + test "preloaded_module_packages handles missing assets gracefully" do + importmap = Importmap::Map.new.tap do |map| + map.pin "existing", to: "application.js", preload: true + map.pin "missing", to: "nonexistent.js", preload: true + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + assert_equal 1, packages.size + + existing_path = packages.keys.find { |path| path&.include?("application") } + assert existing_path, "Should include existing asset" + end + private def generate_importmap_json @generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers) From bb0cd8eae4ca4e0587e317eede37d978022ca9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 9 Jul 2025 23:29:17 +0000 Subject: [PATCH 4/6] Import the integrity hashes for packages from JSPM when pinning And add a new command to download and add integrity hashes for packages. --- README.md | 91 ++++++++++++++++- lib/importmap/commands.rb | 106 ++++++++++++++++---- lib/importmap/packager.rb | 42 +++++--- test/commands_test.rb | 158 ++++++++++++++++++++++++++++-- test/dummy/bin/importmap | 4 + test/packager_integration_test.rb | 4 +- test/packager_test.rb | 115 +++++++++++++++++++++- 7 files changed, 469 insertions(+), 51 deletions(-) create mode 100755 test/dummy/bin/importmap diff --git a/README.md b/README.md index 30e10bfe..2d917c42 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ import React from "./node_modules/react" import React from "https://ga.jspm.io/npm:react@17.0.1/index.js" ``` -Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"` +Importmap-rails provides a clean API for mapping "bare module specifiers" like `"react"` to 1 of the 3 viable ways of loading ES Module javascript packages. For example: @@ -54,11 +54,11 @@ For example: pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` -means "everytime you see `import React from "react"` +means "everytime you see `import React from "react"` change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`" ```js -import React from "react" +import React from "react" // => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` @@ -131,6 +131,91 @@ If you later wish to remove a downloaded pin: Unpinning and removing "react" ``` +## Subresource Integrity (SRI) + +For enhanced security, importmap-rails automatically includes [Subresource Integrity (SRI)](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes by default when pinning packages. This ensures that JavaScript files loaded from CDNs haven't been tampered with. + +### Default behavior with integrity + +When you pin a package, integrity hashes are automatically included: + +```bash +./bin/importmap pin lodash +Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js + Using integrity: sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF +``` + +This generates a pin in your `config/importmap.rb` with the integrity hash: + +```ruby +pin "lodash", integrity: "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" # @4.17.21 +``` + +### Opting out of integrity + +If you need to disable integrity checking (not recommended for security reasons), you can use the `--no-integrity` flag: + +```bash +./bin/importmap pin lodash --no-integrity +Pinning "lodash" to vendor/javascript/lodash.js via download from https://ga.jspm.io/npm:lodash@4.17.21/lodash.js +``` + +This generates a pin without integrity: + +```ruby +pin "lodash" # @4.17.21 +``` + +### Adding integrity to existing pins + +If you have existing pins without integrity hashes, you can add them using the `integrity` command: + +```bash +# Add integrity to specific packages +./bin/importmap integrity lodash react + +# Add integrity to all pinned packages +./bin/importmap integrity + +# Update your importmap.rb file with integrity hashes +./bin/importmap integrity --update +``` + +### How integrity works + +The integrity hashes are automatically included in your import map and module preload tags: + +**Import map JSON:** +```json +{ + "imports": { + "lodash": "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js" + }, + "integrity": { + "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js": "sha384-PkIkha4kVPRlGtFantHjuv+Y9mRefUHpLFQbgOYUjzy247kvi16kLR7wWnsAmqZF" + } +} +``` + +**Module preload tags:** +```html + +``` + +Modern browsers will automatically validate these integrity hashes when loading the JavaScript modules, ensuring the files haven't been modified. + +### Redownloading packages with integrity + +The `pristine` command also includes integrity by default: + +```bash +# Redownload all packages with integrity (default) +./bin/importmap pristine + +# Redownload packages without integrity +./bin/importmap pristine --no-integrity +``` + ## Preloading pinned modules To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, importmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin. diff --git a/lib/importmap/commands.rb b/lib/importmap/commands.rb index d3fda40f..3b5fd55e 100644 --- a/lib/importmap/commands.rb +++ b/lib/importmap/commands.rb @@ -13,21 +13,19 @@ def self.exit_on_failure? option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" option :preload, type: :string, repeatable: true, desc: "Can be used multiple times" + option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM" def pin(*packages) - if imports = packager.import(*packages, env: options[:env], from: options[:from]) - imports.each do |package, url| + with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}) + packager.download(package, url) - pin = packager.vendored_pin_for(package, url, options[:preload]) - if packager.packaged?(package) - gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false) - else - append_to_file("config/importmap.rb", "#{pin}\n", verbose: false) - end + pin = packager.vendored_pin_for(package, url, options[:preload], integrity: integrity_hash) + + log_integrity_usage(integrity_hash) + update_importmap_with_pin(package, pin) end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end @@ -35,33 +33,31 @@ def pin(*packages) option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" def unpin(*packages) - if imports = packager.import(*packages, env: options[:env], from: options[:from]) + with_import_response(packages, env: options[:env], from: options[:from]) do |imports, _integrity_hashes| imports.each do |package, url| if packager.packaged?(package) puts %(Unpinning and removing "#{package}") packager.remove(package) end end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end desc "pristine", "Redownload all pinned packages" option :env, type: :string, aliases: :e, default: "production" option :from, type: :string, aliases: :f, default: "jspm" + option :integrity, type: :boolean, aliases: :i, default: true, desc: "Include integrity hash from JSPM" def pristine - packages = npm.packages_with_versions.map do |p, v| - v.blank? ? p : [p, v].join("@") - end + packages = prepare_packages_with_versions - if imports = packager.import(*packages, env: options[:env], from: options[:from]) - imports.each do |package, url| + with_import_response(packages, env: options[:env], from: options[:from], integrity: options[:integrity]) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}) + packager.download(package, url) + + log_integrity_usage(integrity_hash) end - else - puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}" end end @@ -122,6 +118,33 @@ def packages puts npm.packages_with_versions.map { |x| x.join(' ') } end + desc "integrity [*PACKAGES]", "Download and add integrity hashes for packages" + option :env, type: :string, aliases: :e, default: "production" + option :from, type: :string, aliases: :f, default: "jspm" + option :update, type: :boolean, aliases: :u, default: false, desc: "Update importmap.rb with integrity hashes" + def integrity(*packages) + packages = prepare_packages_with_versions(packages) + + with_import_response(packages, env: options[:env], from: options[:from], integrity: true) do |imports, integrity_hashes| + process_imports(imports, integrity_hashes) do |package, url, integrity_hash| + puts %(Getting integrity for "#{package}" from #{url}) + + if integrity_hash + puts %( #{package}: #{integrity_hash}) + + if options[:update] + pin_with_integrity = packager.pin_for(package, url, integrity: integrity_hash) + + update_importmap_with_pin(package, pin_with_integrity) + puts %( Updated importmap.rb with integrity for "#{package}") + end + else + puts %( No integrity hash available for "#{package}") + end + end + end + end + private def packager @packager ||= Importmap::Packager.new @@ -131,6 +154,22 @@ def npm @npm ||= Importmap::Npm.new end + def update_importmap_with_pin(package, pin) + if packager.packaged?(package) + gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false) + else + append_to_file("config/importmap.rb", "#{pin}\n", verbose: false) + end + end + + def log_integrity_usage(integrity_hash) + puts %( Using integrity: #{integrity_hash}) if integrity_hash + end + + def handle_package_not_found(packages, from) + puts "Couldn't find any packages in #{packages.inspect} on #{from}" + end + def remove_line_from_file(path, pattern) path = File.expand_path(path, destination_root) @@ -155,6 +194,33 @@ def puts_table(array) puts divider if row_number == 0 end end + + def prepare_packages_with_versions(packages = []) + if packages.empty? + npm.packages_with_versions.map do |p, v| + v.blank? ? p : [p, v].join("@") + end + else + packages + end + end + + def process_imports(imports, integrity_hashes, &block) + imports.each do |package, url| + integrity_hash = integrity_hashes[url] + block.call(package, url, integrity_hash) + end + end + + def with_import_response(packages, **options) + response = packager.import(*packages, **options) + + if response + yield response[:imports], response[:integrity] + else + handle_package_not_found(packages, options[:from]) + end + end end Importmap::Commands.start(ARGV) diff --git a/lib/importmap/packager.rb b/lib/importmap/packager.rb index 9a8a8892..98819d13 100644 --- a/lib/importmap/packager.rb +++ b/lib/importmap/packager.rb @@ -17,34 +17,39 @@ def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/java @vendor_path = Pathname.new(vendor_path) end - def import(*packages, env: "production", from: "jspm") + def import(*packages, env: "production", from: "jspm", integrity: false) response = post_json({ "install" => Array(packages), "flattenScope" => true, "env" => [ "browser", "module", env ], - "provider" => normalize_provider(from) + "provider" => normalize_provider(from), + "integrity" => integrity }) case response.code - when "200" then extract_parsed_imports(response) - when "404", "401" then nil - else handle_failure_response(response) + when "200" + extract_parsed_response(response) + when "404", "401" + nil + else + handle_failure_response(response) end end - def pin_for(package, url) - %(pin "#{package}", to: "#{url}") + def pin_for(package, url = nil, preloads: nil, integrity: nil) + to = url ? %(, to: "#{url}") : "" + preload_param = preload(preloads) + integrity_param = integrity ? %(, integrity: "#{integrity}") : "" + + %(pin "#{package}") + to + preload_param + integrity_param end - def vendored_pin_for(package, url, preloads = nil) + def vendored_pin_for(package, url, preloads = nil, integrity: nil) filename = package_filename(package) version = extract_package_version_from(url) + to = "#{package}.js" != filename ? filename : nil - if "#{package}.js" == filename - %(pin "#{package}"#{preload(preloads)} # #{version}) - else - %(pin "#{package}", to: "#{filename}"#{preload(preloads)} # #{version}) - end + pin_for(package, to, preloads: preloads, integrity: integrity) + %( # #{version}) end def packaged?(package) @@ -88,8 +93,15 @@ def normalize_provider(name) name.to_s == "jspm" ? "jspm.io" : name.to_s end - def extract_parsed_imports(response) - JSON.parse(response.body).dig("map", "imports") + def extract_parsed_response(response) + parsed = JSON.parse(response.body) + imports = parsed.dig("map", "imports") + integrity = parsed.dig("map", "integrity") || {} + + { + imports: imports, + integrity: integrity + } end def handle_failure_response(response) diff --git a/test/commands_test.rb b/test/commands_test.rb index d5b3c27e..410248b9 100644 --- a/test/commands_test.rb +++ b/test/commands_test.rb @@ -8,7 +8,6 @@ class CommandsTest < ActiveSupport::TestCase @tmpdir = Dir.mktmpdir FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) Dir.chdir("#{@tmpdir}/dummy") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") end teardown do @@ -16,32 +15,28 @@ class CommandsTest < ActiveSupport::TestCase end test "json command prints JSON with imports" do - out, err = run_importmap_command("json") + out, _err = run_importmap_command("json") + assert_includes JSON.parse(out), "imports" end test "update command prints message of no outdated packages" do out, _err = run_importmap_command("update") + assert_includes out, "No outdated" end test "update command prints confirmation of pin with outdated packages" do - @tmpdir = Dir.mktmpdir - FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) - Dir.chdir("#{@tmpdir}/dummy") FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") out, _err = run_importmap_command("update") + assert_includes out, "Pinning" end test "pristine command redownloads all pinned packages" do - @tmpdir = Dir.mktmpdir - FileUtils.cp_r("#{__dir__}/dummy", @tmpdir) - Dir.chdir("#{@tmpdir}/dummy") FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") - FileUtils.cp("#{__dir__}/../lib/install/bin/importmap", "bin") + out, _err = run_importmap_command("pin", "md5@2.2.0") assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' @@ -55,6 +50,149 @@ class CommandsTest < ActiveSupport::TestCase assert_equal original, File.read("#{@tmpdir}/dummy/vendor/javascript/md5.js") end + test "pin command includes integrity by default" do + out, _err = run_importmap_command("pin", "md5@2.2.0") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", integrity: "sha384-' + end + + test "pin command with --no-integrity option excludes integrity" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_not_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5" # @2.2.0' + end + + test "pristine command includes integrity by default" do + FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") + + out, _err = run_importmap_command("pristine") + + assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + end + + test "pristine command with --no-integrity option excludes integrity" do + FileUtils.cp("#{__dir__}/fixtures/files/outdated_import_map.rb", "#{@tmpdir}/dummy/config/importmap.rb") + + out, _err = run_importmap_command("pristine", "--no-integrity") + + assert_includes out, 'Downloading "md5" to vendor/javascript/md5.js from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_not_includes out, 'Using integrity:' + end + + test "pin command with explicit --integrity option includes integrity" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--integrity") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'integrity: "sha384-' + end + + test "pin command with multiple packages includes integrity for all" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "lodash@4.17.21") + + assert_includes out, 'Pinning "md5"' + assert_includes out, 'Pinning "lodash"' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5"' + assert_includes config_content, 'pin "lodash"' + + md5_lines = config_content.lines.select { |line| line.include?('pin "md5"') } + lodash_lines = config_content.lines.select { |line| line.include?('pin "lodash"') } + assert md5_lines.any? { |line| line.include?('integrity:') } + assert lodash_lines.any? { |line| line.include?('integrity:') } + end + + test "pin command with preload option includes integrity and preload" do + out, _err = run_importmap_command("pin", "md5@2.2.0", "--preload", "true") + + assert_includes out, 'Pinning "md5" to vendor/javascript/md5.js via download from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'Using integrity:' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'preload: true' + assert_includes config_content, 'integrity: "sha384-' + end + + test "integrity command shows integrity hashes for specific packages" do + out, _err = run_importmap_command("integrity", "md5@2.2.0") + + assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with --update option updates importmap.rb" do + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://cdn.skypack.dev/md5", preload: true' + + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--update") + + assert_includes out, 'Getting integrity for "md5" from https://ga.jspm.io/npm:md5@2.2.0/md5.js' + assert_includes out, 'md5: sha384-' + assert_includes out, 'Updated importmap.rb with integrity for "md5"' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-' + end + + test "integrity command with multiple packages shows integrity for all" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "lodash@4.17.21") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'Getting integrity for "lodash"' + assert_includes out, 'md5: sha384-' + assert_includes out, 'lodash: sha384-' + end + + test "integrity command without packages shows integrity for all remote packages" do + run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + + out, _err = run_importmap_command("integrity") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with --update updates multiple packages" do + run_importmap_command("pin", "md5@2.2.0", "--no-integrity") + run_importmap_command("pin", "lodash@4.17.21", "--no-integrity") + + out, _err = run_importmap_command("integrity", "--update") + + assert_includes out, 'Updated importmap.rb with integrity for "md5"' + assert_includes out, 'Updated importmap.rb with integrity for "lodash"' + + config_content = File.read("#{@tmpdir}/dummy/config/importmap.rb") + assert_includes config_content, 'pin "md5", to: "https://ga.jspm.io/npm:md5@2.2.0/md5.js", integrity: "sha384-' + assert_includes config_content, 'pin "lodash", to: "https://ga.jspm.io/npm:lodash@4.17.21/lodash.js", integrity: "sha384-' + end + + test "integrity command with env option" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--env", "development") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + + test "integrity command with from option" do + out, _err = run_importmap_command("integrity", "md5@2.2.0", "--from", "jspm") + + assert_includes out, 'Getting integrity for "md5"' + assert_includes out, 'md5: sha384-' + end + private def run_importmap_command(command, *args) capture_subprocess_io { system("bin/importmap", command, *args, exception: true) } diff --git a/test/dummy/bin/importmap b/test/dummy/bin/importmap new file mode 100755 index 00000000..36502ab1 --- /dev/null +++ b/test/dummy/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/test/packager_integration_test.rb b/test/packager_integration_test.rb index 36000656..148a65ea 100644 --- a/test/packager_integration_test.rb +++ b/test/packager_integration_test.rb @@ -5,7 +5,8 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase setup { @packager = Importmap::Packager.new(Rails.root.join("config/importmap.rb")) } test "successful import against live service" do - assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", @packager.import("react@17.0.2")["react"] + result = @packager.import("react@17.0.2") + assert_equal "https://ga.jspm.io/npm:react@17.0.2/index.js", result[:imports]["react"] end test "missing import against live service" do @@ -40,7 +41,6 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase @packager.download("react", package_url) assert File.exist?(vendored_package_file) assert_equal "// react@17.0.2 downloaded from #{package_url}", File.readlines(vendored_package_file).first.strip - @packager.remove("react") assert_not File.exist?(Pathname.new(vendor_dir).join("react.js")) end diff --git a/test/packager_test.rb b/test/packager_test.rb index a4ba75c6..ad367057 100644 --- a/test/packager_test.rb +++ b/test/packager_test.rb @@ -22,7 +22,9 @@ def code() "200" end end.new @packager.stub(:post_json, response) do - assert_equal(response.imports, @packager.import("react@17.0.2")) + result = @packager.import("react@17.0.2") + assert_equal response.imports, result[:imports] + assert_equal({}, result[:integrity]) end end @@ -49,6 +51,51 @@ def code() "200" end test "pin_for" do assert_equal %(pin "react", to: "https://cdn/react"), @packager.pin_for("react", "https://cdn/react") + assert_equal( + %(pin "react", to: "https://cdn/react", integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react"), + @packager.pin_for("react", "https://cdn/react", integrity: nil) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: true), + @packager.pin_for("react", "https://cdn/react", preloads: ["true"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: false), + @packager.pin_for("react", "https://cdn/react", preloads: ["false"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: "foo"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"]), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"]) + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: true, integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["true"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: false, integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["false"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: "foo", integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", to: "https://cdn/react", preload: ["foo", "bar"], integrity: "sha384-abcdef"), + @packager.pin_for("react", "https://cdn/react", preloads: ["foo", "bar"], integrity: "sha384-abcdef") + ) + + assert_equal %(pin "react"), @packager.pin_for("react") + assert_equal %(pin "react", preload: true), @packager.pin_for("react", preloads: ["true"]) + assert_equal %(pin "react", integrity: "sha384-abcdef"), @packager.pin_for("react", integrity: "sha384-abcdef") + assert_equal %(pin "react", preload: true, integrity: "sha384-abcdef"), @packager.pin_for("react", preloads: ["true"], integrity: "sha384-abcdef") end test "vendored_pin_for" do @@ -58,5 +105,71 @@ def code() "200" end assert_equal %(pin "react", preload: false # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"]) assert_equal %(pin "react", preload: "foo" # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"]) assert_equal %(pin "react", preload: ["foo", "bar"] # @17.0.2), @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"]) + assert_equal( + %(pin "react", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "javascript/react", to: "javascript--react.js", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("javascript/react", "https://cdn/react@17.0.2", nil, integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: true, integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["true"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: false, integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["false"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: "foo", integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react", preload: ["foo", "bar"], integrity: "sha384-abcdef" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", ["foo", "bar"], integrity: "sha384-abcdef") + ) + assert_equal( + %(pin "react" # @17.0.2), + @packager.vendored_pin_for("react", "https://cdn/react@17.0.2", nil, integrity: nil) + ) + end + + test "import with integrity parameter" do + response = Class.new do + def body + { + "map" => { + "imports" => imports, + "integrity" => integrity_map + } + }.to_json + end + + def imports + { + "react" => "https://ga.jspm.io/npm:react@17.0.2/index.js", + "object-assign" => "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" + } + end + + def integrity_map + { + "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890", + "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef" + } + end + + def code() "200" end + end.new + + @packager.stub(:post_json, response) do + result = @packager.import("react@17.0.2", integrity: true) + assert_equal response.imports, result[:imports] + assert_equal({ + "https://ga.jspm.io/npm:react@17.0.2/index.js" => "sha384-abcdef1234567890", + "https://ga.jspm.io/npm:object-assign@4.1.1/index.js" => "sha384-1234567890abcdef" + }, result[:integrity]) + end end end From 646b206771fa24eaded47701cded5b842ac234c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 14 Jul 2025 20:08:47 +0000 Subject: [PATCH 5/6] Fix typos --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d917c42..39dd72e1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ For example: pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js" ``` -means "everytime you see `import React from "react"` +means "every time you see `import React from "react"` change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`" ```js @@ -302,7 +302,7 @@ Pin your js file: pin "checkout", preload: false ``` -Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specifc page/partial, then yield it in your layout. +Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specific page/partial, then yield it in your layout. ```erb <% content_for :head do %> From 1aecf66e89776eb10c8486bd1a65ef0bf07e60c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 15 Jul 2025 16:19:38 +0000 Subject: [PATCH 6/6] Support calculating integrity hashes for local assets automatically When `integrity: true` is used with `pin_all_from` or `pin`, the importmap will automatically calculate integrity hashes for local assets served by the Rails asset pipeline. This eliminates the need to manually manage integrity hashes for local files, enhancing security and simplifying development. --- .github/workflows/ci.yml | 1 + Appraisals | 5 -- Gemfile | 2 +- Gemfile.lock | 9 ++- README.md | 51 ++++++++++++++++ gemfiles/rails_7.0_propshaft.gemfile | 2 +- gemfiles/rails_7.1_propshaft.gemfile | 2 +- gemfiles/rails_7.2_propshaft.gemfile | 2 +- gemfiles/rails_8.0_propshaft.gemfile | 2 +- gemfiles/rails_main_propshaft.gemfile | 2 +- lib/importmap/map.rb | 45 +++++++++++--- test/dummy/config/initializers/assets.rb | 2 + test/importmap_test.rb | 76 ++++++++++++++++++++++++ 13 files changed, 177 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5e3044..04228528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: env: BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails-version }}_${{ matrix.assets-pipeline }}.gemfile + ASSETS_PIPELINE: ${{ matrix.assets-pipeline }} steps: - uses: actions/checkout@v4 diff --git a/Appraisals b/Appraisals index 6843f039..e2be1b68 100644 --- a/Appraisals +++ b/Appraisals @@ -17,7 +17,6 @@ end appraise "rails_7.0_propshaft" do gem "rails", github: "rails/rails", branch: "7-0-stable" - gem "propshaft" gem "sqlite3", "~> 1.4" end @@ -29,7 +28,6 @@ end appraise "rails_7.1_propshaft" do gem "rails", "~> 7.1.0" - gem "propshaft" end appraise "rails_7.2_sprockets" do @@ -40,7 +38,6 @@ end appraise "rails_7.2_propshaft" do gem "rails", "~> 7.2.0" - gem "propshaft" end appraise "rails_8.0_sprockets" do @@ -51,7 +48,6 @@ end appraise "rails_8.0_propshaft" do gem "rails", "~> 8.0.0" - gem "propshaft" end appraise "rails_main_sprockets" do @@ -62,5 +58,4 @@ end appraise "rails_main_propshaft" do gem "rails", github: "rails/rails", branch: "main" - gem "propshaft" end diff --git a/Gemfile b/Gemfile index a144c5ef..cdd020f2 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } gemspec gem "rails" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" diff --git a/Gemfile.lock b/Gemfile.lock index 09d05d74..150f47fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,13 +105,13 @@ GEM crass (1.0.6) date (3.4.1) drb (2.2.3) - erb (5.0.1) + erb (5.0.2) erubi (1.13.1) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -150,11 +150,10 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - propshaft (1.1.0) + propshaft (1.2.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) psych (5.2.6) date stringio @@ -257,7 +256,7 @@ DEPENDENCIES byebug capybara importmap-rails! - propshaft + propshaft (>= 1.2.0) rails rexml selenium-webdriver diff --git a/README.md b/README.md index 39dd72e1..bc2b2b28 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,15 @@ If you want to import local js module files from `app/javascript/src` or other s ```rb # config/importmap.rb pin_all_from 'app/javascript/src', under: 'src', to: 'src' + +# With automatic integrity calculation for enhanced security +pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true ``` The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter. +The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management. + Allows you to: ```js @@ -181,6 +186,52 @@ If you have existing pins without integrity hashes, you can add them using the ` ./bin/importmap integrity --update ``` +### Automatic integrity for local assets + +For local assets served by the Rails asset pipeline (like those created with `pin` or `pin_all_from`), you can use `integrity: true` to automatically calculate integrity hashes from the compiled assets: + +```ruby +# config/importmap.rb + +# Automatically calculate integrity from asset pipeline +pin "application", integrity: true +pin "admin", to: "admin.js", integrity: true + +# Works with pin_all_from too +pin_all_from "app/javascript/controllers", under: "controllers", integrity: true +pin_all_from "app/javascript/lib", under: "lib", integrity: true + +# Mixed usage +pin "local_module", integrity: true # Auto-calculated +pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated +pin "no_integrity_package" # No integrity (default) +``` + +This is particularly useful for: +* **Local JavaScript files** managed by your Rails asset pipeline +* **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious +* **Development workflow** where asset contents change frequently + +The `integrity: true` option: +* Uses the Rails asset pipeline's built-in integrity calculation +* Works with both Sprockets and Propshaft +* Automatically updates when assets are recompiled +* Gracefully handles missing assets (returns `nil` for non-existent files) + +**Example output with `integrity: true`:** +```json +{ + "imports": { + "application": "/assets/application-abc123.js", + "controllers/hello_controller": "/assets/controllers/hello_controller-def456.js" + }, + "integrity": { + "/assets/application-abc123.js": "sha256-xyz789...", + "/assets/controllers/hello_controller-def456.js": "sha256-uvw012..." + } +} +``` + ### How integrity works The integrity hashes are automatically included in your import map and module preload tags: diff --git a/gemfiles/rails_7.0_propshaft.gemfile b/gemfiles/rails_7.0_propshaft.gemfile index 6096f3a5..896a5b6f 100644 --- a/gemfiles/rails_7.0_propshaft.gemfile +++ b/gemfiles/rails_7.0_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", branch: "7-0-stable", git: "https://github.com/rails/rails.git" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3", "~> 1.4" group :development do diff --git a/gemfiles/rails_7.1_propshaft.gemfile b/gemfiles/rails_7.1_propshaft.gemfile index df7a907f..e695ca46 100644 --- a/gemfiles/rails_7.1_propshaft.gemfile +++ b/gemfiles/rails_7.1_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 7.1.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_7.2_propshaft.gemfile b/gemfiles/rails_7.2_propshaft.gemfile index c0812343..dff1f0cf 100644 --- a/gemfiles/rails_7.2_propshaft.gemfile +++ b/gemfiles/rails_7.2_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 7.2.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_8.0_propshaft.gemfile b/gemfiles/rails_8.0_propshaft.gemfile index 034cb716..c6acfd33 100644 --- a/gemfiles/rails_8.0_propshaft.gemfile +++ b/gemfiles/rails_8.0_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 8.0.0" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/gemfiles/rails_main_propshaft.gemfile b/gemfiles/rails_main_propshaft.gemfile index 3de5eecc..a29a3965 100644 --- a/gemfiles/rails_main_propshaft.gemfile +++ b/gemfiles/rails_main_propshaft.gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gem "rails", branch: "main", git: "https://github.com/rails/rails.git" -gem "propshaft" +gem "propshaft", ">= 1.2.0" gem "sqlite3" group :development do diff --git a/lib/importmap/map.rb b/lib/importmap/map.rb index 24344460..63fa2ef2 100644 --- a/lib/importmap/map.rb +++ b/lib/importmap/map.rb @@ -30,9 +30,9 @@ def pin(name, to: nil, preload: true, integrity: nil) @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity) end - def pin_all_from(dir, under: nil, to: nil, preload: true) + def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: nil) clear_cache - @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload) + @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity) end # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to @@ -92,9 +92,21 @@ def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :pr # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host") def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages) cache_as(cache_key) do - expanded_preloading_packages_and_directories(entry_point:).to_h do |_, package| - [resolve_asset_path(package.path, resolver: resolver), package] - end.delete_if { |key| key.nil? } + expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package| + resolved_path = resolve_asset_path(package.path, resolver: resolver) + next unless resolved_path + + resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver) + + package = MappedFile.new( + name: package.name, + path: package.path, + preload: package.preload, + integrity: resolved_integrity + ) + + [resolved_path, package] + end.to_h end end @@ -138,7 +150,7 @@ def cache_sweeper(watches: nil) end private - MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true) + MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true) MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true) def cache_as(name) @@ -190,10 +202,22 @@ def build_integrity_hash(packages, resolver:) resolved_path = resolve_asset_path(mapping.path, resolver: resolver) next unless resolved_path - [resolved_path, mapping.integrity] + integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver) + next unless integrity_value + + [resolved_path, integrity_value] end.to_h end + def resolve_integrity_value(integrity, path, resolver:) + case integrity + when true + resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity) + when String + integrity + end + end + def expanded_preloading_packages_and_directories(entry_point:) expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? } end @@ -210,7 +234,12 @@ def expand_directories_into(paths) module_name = module_name_from(module_filename, mapping) module_path = module_path_from(module_filename, mapping) - paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload) + paths[module_name] = MappedFile.new( + name: module_name, + path: module_path, + preload: mapping.preload, + integrity: mapping.integrity + ) end end end diff --git a/test/dummy/config/initializers/assets.rb b/test/dummy/config/initializers/assets.rb index 969a5d84..8edcc35d 100644 --- a/test/dummy/config/initializers/assets.rb +++ b/test/dummy/config/initializers/assets.rb @@ -11,3 +11,5 @@ # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. # Rails.application.config.assets.precompile += %w( admin.js admin.css ) + +Rails.application.config.assets.integrity_hash_algorithm = "sha384" diff --git a/test/importmap_test.rb b/test/importmap_test.rb index 0e880792..0f341d4c 100644 --- a/test/importmap_test.rb +++ b/test/importmap_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "minitest/mock" class ImportmapTest < ActiveSupport::TestCase def setup @@ -89,6 +90,52 @@ def setup assert_match %r|assets/my_lib-.*\.js|, generate_importmap_json["imports"]["my_lib"] end + test "importmap json includes integrity hashes from integrity: true" do + importmap = Importmap::Map.new.tap do |map| + map.pin "application", integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers)) + + assert json["integrity"], "Should include integrity section" + + application_path = json["imports"]["application"] + assert application_path, "Should include application in imports" + if ENV["ASSETS_PIPELINE"] == "sprockets" + assert_equal "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", json["integrity"][application_path] + else + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", json["integrity"][application_path] + end + end + + test "integrity: true with missing asset should be gracefully handled" do + importmap = Importmap::Map.new.tap do |map| + map.pin "missing", to: "nonexistent.js", preload: true, integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers)) + + assert_empty json["imports"] + assert_nil json["integrity"] + end + + test "integrity: true with resolver that doesn't have asset_integrity method returns nil" do + mock_resolver = Minitest::Mock.new + + mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"]) + mock_resolver.expect(:path_to_asset, "/assets/application-abc123.js", ["application.js"]) + + importmap = Importmap::Map.new.tap do |map| + map.pin "application", integrity: true + end + + json = JSON.parse(importmap.to_json(resolver: mock_resolver)) + + assert json["imports"]["application"] + assert_match %r|/assets/application-.*\.js|, json["imports"]["application"] + assert_nil json["integrity"] + end + test 'invalid importmap file results in error' do file = file_fixture('invalid_import_map.rb') importmap = Importmap::Map.new @@ -243,6 +290,18 @@ def setup assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity end + test "pin with integrity: true should calculate integrity dynamically" do + importmap = Importmap::Map.new.tap do |map| + map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb" + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + editor_path = packages.keys.find { |path| path.include?("rich_text") } + assert editor_path, "Should include editor package" + assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity + end + test "preloaded_module_packages uses custom cache_key" do set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s @@ -279,6 +338,23 @@ def setup assert existing_path, "Should include existing asset" end + test "pin_all_from with integrity: true should calculate integrity dynamically" do + importmap = Importmap::Map.new.tap do |map| + map.pin_all_from "app/javascript/controllers", under: "controllers", integrity: true + end + + packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers) + + controller_path = packages.keys.find { |path| path.include?("goodbye_controller") } + assert controller_path, "Should include goodbye_controller package" + if ENV["ASSETS_PIPELINE"] == "sprockets" + assert_equal "sha256-6yWqFiaT8vQURc/OiKuIrEv9e/y4DMV/7nh7s5o3svA=", packages[controller_path].integrity + else + assert_equal "sha384-k7HGo2DomvN21em+AypqCekIFE3quejFnjQp3NtEIMyvFNpIdKThZhxr48anSNmP", packages[controller_path].integrity + end + assert_not_includes packages.map { |_, v| v.integrity }, nil + end + private def generate_importmap_json @generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers)