Skip to content

Commit 129a6bd

Browse files
committed
Add Subresource Integrity value to manifest
1 parent 689e756 commit 129a6bd

File tree

9 files changed

+144
-6
lines changed

9 files changed

+144
-6
lines changed

lib/propshaft/assembly.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ def initialize(config)
1616
end
1717

1818
def load_path
19-
@load_path ||= Propshaft::LoadPath.new(config.paths, compilers: compilers, version: config.version)
19+
@load_path ||= Propshaft::LoadPath.new(
20+
config.paths,
21+
compilers: compilers,
22+
version: config.version,
23+
integrity_hash_algorithm: config.integrity_hash_algorithm
24+
)
2025
end
2126

2227
def resolver

lib/propshaft/asset.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "digest/sha1"
2+
require "digest/sha2"
23
require "action_dispatch/http/mime_type"
34

45
class Propshaft::Asset
@@ -33,6 +34,24 @@ def digest
3334
@digest ||= Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8)
3435
end
3536

37+
# Following the Subresource Integrity spec draft
38+
# https://w3c.github.io/webappsec-subresource-integrity/
39+
# allowing only sha256, sha384, and sha512
40+
def integrity(hash_algorithm:)
41+
bitlen = case hash_algorithm
42+
when "sha256"
43+
256
44+
when "sha384"
45+
384
46+
when "sha512"
47+
512
48+
else
49+
raise(Error.new("Subresource Integrity hash algorithm must be one of SHA2 family (sha256, sha384, sha512)"))
50+
end
51+
52+
[hash_algorithm, Digest::SHA2.new(bitlen).base64digest(content)].join("-")
53+
end
54+
3655
def digested_path
3756
if already_digested?
3857
logical_path

lib/propshaft/load_path.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
class Propshaft::LoadPath
44
attr_reader :paths, :compilers, :version
55

6-
def initialize(paths = [], compilers:, version: nil)
7-
@paths, @compilers, @version = dedup(paths), compilers, version
6+
def initialize(paths = [], compilers:, version: nil, integrity_hash_algorithm: nil)
7+
@paths, @compilers, @version, @integrity_hash_algorithm = dedup(paths), compilers, version, integrity_hash_algorithm
88
end
99

1010
def find(asset_name)
@@ -32,7 +32,14 @@ def asset_paths_by_glob(glob)
3232
def manifest
3333
Hash.new.tap do |manifest|
3434
assets.each do |asset|
35-
manifest[asset.logical_path.to_s] = asset.digested_path.to_s
35+
manifest[asset.logical_path.to_s] = if @integrity_hash_algorithm.nil?
36+
asset.digested_path.to_s
37+
else
38+
{
39+
digested_path: asset.digested_path.to_s,
40+
integrity: asset.integrity(hash_algorithm: @integrity_hash_algorithm)
41+
}
42+
end
3643
end
3744
end
3845
end

lib/propshaft/resolver/static.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ def initialize(manifest_path:, prefix:)
77
end
88

99
def resolve(logical_path)
10-
if asset_path = parsed_manifest[logical_path]
10+
if asset_path = digested_path(logical_path)
1111
File.join prefix, asset_path
1212
end
1313
end
1414

1515
def read(logical_path, encoding: "ASCII-8BIT")
16-
if asset_path = parsed_manifest[logical_path]
16+
if asset_path = digested_path(logical_path)
1717
File.read(manifest_path.dirname.join(asset_path), encoding: encoding)
1818
end
1919
end
@@ -22,5 +22,15 @@ def read(logical_path, encoding: "ASCII-8BIT")
2222
def parsed_manifest
2323
@parsed_manifest ||= JSON.parse(manifest_path.read, symbolize_names: false)
2424
end
25+
26+
def digested_path(logical_path)
27+
entry = parsed_manifest[logical_path]
28+
29+
if entry.is_a?(String)
30+
return entry
31+
elsif entry.is_a?(Hash)
32+
entry["digested_path"]
33+
end
34+
end
2535
end
2636
end
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"one.txt": {"digested_path": "one-f2e1ec14.txt","integrity": "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe"}}

test/propshaft/assembly_test.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,30 @@ class Propshaft::AssemblyTest < ActiveSupport::TestCase
4343

4444
assert assembly.processor.is_a?(Propshaft::Processor)
4545
end
46+
47+
class Propshaft::AssemblyTest::WithIntegrityTest < ActiveSupport::TestCase
48+
test "uses static resolver when manifest is present" do
49+
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
50+
config.output_path = Pathname.new("#{__dir__}/../fixtures/output")
51+
config.manifest_path = config.output_path.join(".manifest_with_integrity.json")
52+
config.prefix = "/assets"
53+
54+
config.integrity_hash_algorithm = "sha384"
55+
})
56+
57+
assert assembly.resolver.is_a?(Propshaft::Resolver::Static)
58+
end
59+
60+
test "uses dynamic resolver when manifest is missing" do
61+
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
62+
config.output_path = Pathname.new("#{__dir__}/../fixtures/assets")
63+
config.manifest_path = config.output_path.join(".manifest_with_integrity.json")
64+
config.prefix = "/assets"
65+
66+
config.integrity_hash_algorithm = "sha384"
67+
})
68+
69+
assert assembly.resolver.is_a?(Propshaft::Resolver::Dynamic)
70+
end
71+
end
4672
end

test/propshaft/output_path_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Propshaft::OutputPathTest < ActiveSupport::TestCase
88
setup do
99
@manifest = {
1010
".manifest.json": ".manifest.json",
11+
".manifest_with_integrity.json": ".manifest_with_integrity.json",
1112
"one.txt": "one-f2e1ec14.txt",
1213
"one.txt.map": "one-f2e1ec15.txt.map"
1314
}.stringify_keys

test/propshaft/processor_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,35 @@ class Propshaft::ProcessorTest < ActiveSupport::TestCase
2121
end
2222
end
2323

24+
25+
test "new manifest version is written" do
26+
assembly = Propshaft::Assembly.new(ActiveSupport::OrderedOptions.new.tap { |config|
27+
config.output_path = Pathname.new("#{__dir__}/../fixtures/output")
28+
config.prefix = "/assets"
29+
config.paths = [
30+
Pathname.new("#{__dir__}/../fixtures/assets/first_path"),
31+
Pathname.new("#{__dir__}/../fixtures/assets/second_path")
32+
]
33+
34+
config.integrity_hash_algorithm = "sha384"
35+
})
36+
37+
Dir.mktmpdir do |output_path|
38+
output_path = Pathname.new(output_path)
39+
processor = Propshaft::Processor.new(
40+
load_path: assembly.load_path, output_path: output_path,
41+
compilers: assembly.compilers, manifest_path: output_path.join(".manifest.json")
42+
)
43+
44+
processor.process
45+
46+
manifest_entry = JSON.parse(processor.output_path.join(".manifest.json").read)["one.txt"]
47+
48+
assert_equal manifest_entry["digested_path"], "one-f2e1ec14.txt"
49+
assert_equal manifest_entry["integrity"], "sha384-LdS8l2QTAF8bD8WPb8QSQv0skTWHhmcnS2XU5LBkVQneGzqIqnDRskQtJvi7ADMe"
50+
end
51+
end
52+
2453
test "assets are copied" do
2554
processed do |processor|
2655
digested_asset_name = "one-f2e1ec14.txt"

test/propshaft/resolver/static_test.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,44 @@ class Propshaft::Resolver::StaticTest < ActiveSupport::TestCase
4040
@resolver.resolve("one.txt")
4141
end
4242
end
43+
44+
class Propshaft::Resolver::StaticTest::WithIntegrityTest < ActiveSupport::TestCase
45+
setup do
46+
@resolver = Propshaft::Resolver::Static.new(
47+
manifest_path: Pathname.new("#{__dir__}/../../fixtures/output/.manifest_with_integrity.json"),
48+
prefix: "/assets"
49+
)
50+
end
51+
52+
test "resolving present asset returns uri path" do
53+
assert_equal \
54+
"/assets/one-f2e1ec14.txt",
55+
@resolver.resolve("one.txt")
56+
end
57+
58+
test "reading static asset" do
59+
assert_equal "ASCII-8BIT", @resolver.read("one.txt").encoding.to_s
60+
assert_equal "One from first path", @resolver.read("one.txt")
61+
end
62+
63+
test "reading static asset with encoding option" do
64+
assert_equal "UTF-8", @resolver.read("one.txt", encoding: "UTF-8").encoding.to_s
65+
assert_equal "One from first path", @resolver.read("one.txt", encoding: "UTF-8")
66+
end
67+
68+
test "resolving missing asset returns nil" do
69+
assert_nil @resolver.resolve("nowhere.txt")
70+
end
71+
72+
test "resolver requests json optimizer gems to keep parsed manifest keys as strings" do
73+
stub = Proc.new do |_, opts|
74+
assert_equal false, opts[:symbolize_names]
75+
{}
76+
end
77+
78+
JSON.stub :parse, stub do
79+
@resolver.resolve("one.txt")
80+
end
81+
end
82+
end
4383
end

0 commit comments

Comments
 (0)