Skip to content

Commit 7f9ff28

Browse files
committed
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.
1 parent 7e2f7d7 commit 7f9ff28

File tree

6 files changed

+153
-16
lines changed

6 files changed

+153
-16
lines changed

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
55
gemspec
66

77
gem "rails"
8-
gem "propshaft"
8+
gem "propshaft", github: "rails/propshaft", branch: "main"
99

1010
gem "sqlite3"
1111

Gemfile.lock

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
GIT
2+
remote: https://github.com/rails/propshaft.git
3+
revision: 9bcad5fa48efac2f26568e5d019409a1784839d0
4+
branch: main
5+
specs:
6+
propshaft (1.1.0)
7+
actionpack (>= 7.0.0)
8+
activesupport (>= 7.0.0)
9+
rack
10+
111
PATH
212
remote: .
313
specs:
@@ -111,7 +121,7 @@ GEM
111121
activesupport (>= 6.1)
112122
i18n (1.14.7)
113123
concurrent-ruby (~> 1.0)
114-
io-console (0.8.0)
124+
io-console (0.8.1)
115125
irb (1.15.2)
116126
pp (>= 0.6.0)
117127
rdoc (>= 4.0.0)
@@ -150,11 +160,6 @@ GEM
150160
pp (0.6.2)
151161
prettyprint
152162
prettyprint (0.2.0)
153-
propshaft (1.1.0)
154-
actionpack (>= 7.0.0)
155-
activesupport (>= 7.0.0)
156-
rack
157-
railties (>= 7.0.0)
158163
psych (5.2.6)
159164
date
160165
stringio
@@ -257,7 +262,7 @@ DEPENDENCIES
257262
byebug
258263
capybara
259264
importmap-rails!
260-
propshaft
265+
propshaft!
261266
rails
262267
rexml
263268
selenium-webdriver

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,15 @@ If you want to import local js module files from `app/javascript/src` or other s
7979
```rb
8080
# config/importmap.rb
8181
pin_all_from 'app/javascript/src', under: 'src', to: 'src'
82+
83+
# With automatic integrity calculation for enhanced security
84+
pin_all_from 'app/javascript/controllers', under: 'controllers', integrity: true
8285
```
8386

8487
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.
8588

89+
The `integrity: true` option automatically calculates integrity hashes for all files in the directory, providing security benefits without manual hash management.
90+
8691
Allows you to:
8792

8893
```js
@@ -181,6 +186,52 @@ If you have existing pins without integrity hashes, you can add them using the `
181186
./bin/importmap integrity --update
182187
```
183188

189+
### Automatic integrity for local assets
190+
191+
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:
192+
193+
```ruby
194+
# config/importmap.rb
195+
196+
# Automatically calculate integrity from asset pipeline
197+
pin "application", integrity: true
198+
pin "admin", to: "admin.js", integrity: true
199+
200+
# Works with pin_all_from too
201+
pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
202+
pin_all_from "app/javascript/lib", under: "lib", integrity: true
203+
204+
# Mixed usage
205+
pin "local_module", integrity: true # Auto-calculated
206+
pin "cdn_package", integrity: "sha384-abc123..." # Pre-calculated
207+
pin "no_integrity_package" # No integrity (default)
208+
```
209+
210+
This is particularly useful for:
211+
* **Local JavaScript files** managed by your Rails asset pipeline
212+
* **Bulk operations** with `pin_all_from` where calculating hashes manually would be tedious
213+
* **Development workflow** where asset contents change frequently
214+
215+
The `integrity: true` option:
216+
* Uses the Rails asset pipeline's built-in integrity calculation
217+
* Works with both Sprockets and Propshaft
218+
* Automatically updates when assets are recompiled
219+
* Gracefully handles missing assets (returns `nil` for non-existent files)
220+
221+
**Example output with `integrity: true`:**
222+
```json
223+
{
224+
"imports": {
225+
"application": "/assets/application-abc123.js",
226+
"controllers/hello_controller": "/assets/controllers/hello_controller-def456.js"
227+
},
228+
"integrity": {
229+
"/assets/application-abc123.js": "sha256-xyz789...",
230+
"/assets/controllers/hello_controller-def456.js": "sha256-uvw012..."
231+
}
232+
}
233+
```
234+
184235
### How integrity works
185236

186237
The integrity hashes are automatically included in your import map and module preload tags:

lib/importmap/map.rb

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ def pin(name, to: nil, preload: true, integrity: nil)
3030
@packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
3131
end
3232

33-
def pin_all_from(dir, under: nil, to: nil, preload: true)
33+
def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: nil)
3434
clear_cache
35-
@directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
35+
@directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
3636
end
3737

3838
# 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
9292
# packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
9393
def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
9494
cache_as(cache_key) do
95-
expanded_preloading_packages_and_directories(entry_point:).to_h do |_, package|
96-
[resolve_asset_path(package.path, resolver: resolver), package]
97-
end.delete_if { |key| key.nil? }
95+
expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
96+
resolved_path = resolve_asset_path(package.path, resolver: resolver)
97+
next unless resolved_path
98+
99+
resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
100+
101+
package = MappedFile.new(
102+
name: package.name,
103+
path: package.path,
104+
preload: package.preload,
105+
integrity: resolved_integrity
106+
)
107+
108+
[resolved_path, package]
109+
end.to_h
98110
end
99111
end
100112

@@ -138,7 +150,7 @@ def cache_sweeper(watches: nil)
138150
end
139151

140152
private
141-
MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
153+
MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
142154
MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
143155

144156
def cache_as(name)
@@ -190,10 +202,22 @@ def build_integrity_hash(packages, resolver:)
190202
resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
191203
next unless resolved_path
192204

193-
[resolved_path, mapping.integrity]
205+
integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
206+
next unless integrity_value
207+
208+
[resolved_path, integrity_value]
194209
end.to_h
195210
end
196211

212+
def resolve_integrity_value(integrity, path, resolver:)
213+
case integrity
214+
when true
215+
resolver.asset_integrity(path)
216+
when String
217+
integrity
218+
end
219+
end
220+
197221
def expanded_preloading_packages_and_directories(entry_point:)
198222
expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
199223
end
@@ -210,7 +234,12 @@ def expand_directories_into(paths)
210234
module_name = module_name_from(module_filename, mapping)
211235
module_path = module_path_from(module_filename, mapping)
212236

213-
paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
237+
paths[module_name] = MappedFile.new(
238+
name: module_name,
239+
path: module_path,
240+
preload: mapping.preload,
241+
integrity: mapping.integrity
242+
)
214243
end
215244
end
216245
end

test/dummy/config/initializers/assets.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@
1111
# application.js, application.css, and all non-JS/CSS in the app/assets
1212
# folder are already added.
1313
# Rails.application.config.assets.precompile += %w( admin.js admin.css )
14+
15+
Rails.application.config.assets.integrity_hash_algorithm = "sha384"

test/importmap_test.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,31 @@ def setup
8989
assert_match %r|assets/my_lib-.*\.js|, generate_importmap_json["imports"]["my_lib"]
9090
end
9191

92+
test "importmap json includes integrity hashes from integrity: true" do
93+
importmap = Importmap::Map.new.tap do |map|
94+
map.pin "application", integrity: true
95+
end
96+
97+
json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers))
98+
99+
assert json["integrity"], "Should include integrity section"
100+
101+
application_path = json["imports"]["application"]
102+
assert application_path, "Should include application in imports"
103+
assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", json["integrity"][application_path]
104+
end
105+
106+
test "integrity: true with missing asset should be gracefully handled" do
107+
importmap = Importmap::Map.new.tap do |map|
108+
map.pin "missing", to: "nonexistent.js", preload: true, integrity: true
109+
end
110+
111+
json = JSON.parse(importmap.to_json(resolver: ApplicationController.helpers))
112+
113+
assert_empty json["imports"]
114+
assert_nil json["integrity"]
115+
end
116+
92117
test 'invalid importmap file results in error' do
93118
file = file_fixture('invalid_import_map.rb')
94119
importmap = Importmap::Map.new
@@ -242,6 +267,18 @@ def setup
242267
assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity
243268
end
244269

270+
test "pin with integrity: true should calculate integrity dynamically" do
271+
importmap = Importmap::Map.new.tap do |map|
272+
map.pin "editor", to: "rich_text.js", preload: true, integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
273+
end
274+
275+
packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
276+
277+
editor_path = packages.keys.find { |path| path.include?("rich_text") }
278+
assert editor_path, "Should include editor package"
279+
assert_equal "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", packages[editor_path].integrity
280+
end
281+
245282
test "preloaded_module_packages uses custom cache_key" do
246283
set_one = @importmap.preloaded_module_packages(resolver: ApplicationController.helpers, cache_key: "1").to_s
247284

@@ -278,6 +315,19 @@ def setup
278315
assert existing_path, "Should include existing asset"
279316
end
280317

318+
test "pin_all_from with integrity: true should calculate integrity dynamically" do
319+
importmap = Importmap::Map.new.tap do |map|
320+
map.pin_all_from "app/javascript/controllers", under: "controllers", integrity: true
321+
end
322+
323+
packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
324+
325+
controller_path = packages.keys.find { |path| path.include?("goodbye_controller") }
326+
assert controller_path, "Should include goodbye_controller package"
327+
assert_equal "sha384-k7HGo2DomvN21em+AypqCekIFE3quejFnjQp3NtEIMyvFNpIdKThZhxr48anSNmP", packages[controller_path].integrity
328+
assert_not_includes packages.map { |_, v| v.integrity }, nil
329+
end
330+
281331
private
282332
def generate_importmap_json
283333
@generate_importmap_json ||= JSON.parse @importmap.to_json(resolver: ApplicationController.helpers)

0 commit comments

Comments
 (0)