digest_fields 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea76394e2f56ef9da13f17ae08ee86e8f85f157fdf8b10dbb7cd2eb69256e47b
4
- data.tar.gz: e0ba82e591f64ab9a12c0003a1b224cbe242e71816e5ac6f42f089a168af39bf
3
+ metadata.gz: 724411f2f670ec2fae3f43d2d84cbfbab3ff9fce8625e2acef870a9c7fc94ec5
4
+ data.tar.gz: ceb6a28e84a21e6014dfa64cda9d12736783a5ad6cc33b5c65fa009c9db4e8dd
5
5
  SHA512:
6
- metadata.gz: 809b925c68acdb80c3a178889970bcb3bf8d8e9b72cae9eea48c46554424d9b476109491e0020a4b9c10eb53fdadcff7e2336b26c46e9b2fe37cb4ea7a1f1ebf
7
- data.tar.gz: a32ea12572fb2c7bc6fe3f2006910b2cd604dd81d71c2736ab8cf4f936fc1c5793b2f554487b176abebcd23f99645c8363f7108e615921cf6ae913959874d859
6
+ metadata.gz: f9c6c4624221fe9a21dc9d1d28463e3218c0ecfbb7b5c81d777858a29e28397097c02362300c705079d382a9f45c6a256e6db23f622c5dab536b97b09e659175
7
+ data.tar.gz: b84c7ecdc1888dca1ff176bc235b5edaa69145cf5fc029146e26f58b0e67de76aaae8fc260e185521670e58da54ce818b82bf75d19f5ee5bf4d80781866371de
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -4,19 +4,87 @@
4
4
 
5
5
  Support for `Content-Digest` and `Repl-Digest` header digests that follows the [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) specification.
6
6
 
7
- > [!WARNING]
8
- > This library currently only offers low-level primitives, and does not interact with Rack, does not set headers or trailers, or decide what consistitues content to digest. This is currently an exercise left to the user.
9
-
10
7
  ## Usage
11
8
 
9
+ ### Rack middleware
10
+
11
+ `Rack::DigestFields` automatically computes and injects `Unencoded-Digest` response headers.
12
+
13
+ #### Why `Unencoded-Digest` and not `Content-Digest` or `Repr-Digest`?
14
+
15
+ [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) defines two similar headers:
16
+
17
+ - **`Content-Digest`** — digest of the bytes as transferred on the wire, after any content-encoding (e.g. gzip) is applied.
18
+ - **`Repr-Digest`** — digest of the full selected representation, which also varies with content-encoding.
19
+
20
+ Both require the middleware to know the final encoded bytes. In a typical deployment, content-encoding is applied *downstream* of the Ruby process — by nginx, a CDN, or `Rack::Deflater` positioned later in the stack. The app never sees those bytes, so it cannot correctly compute either header.
21
+
22
+ `Unencoded-Digest` is the digest of the body *before* any content-encoding. A Rack middleware always has these bytes, so it can produce the header reliably regardless of what happens downstream.
23
+
24
+ > [!NOTE]
25
+ > `Unencoded-Digest` is defined in an IETF draft ([draft-ietf-httpbis-unencoded-digest](https://datatracker.ietf.org/doc/draft-ietf-httpbis-unencoded-digest/)) that has not yet been standardised. Breaking changes to the header name, wire format, or semantics are unlikely — current feedback on the draft focuses on security considerations rather than the core design — but it is possible before the RFC is finalised.
26
+
27
+ ```ruby
28
+ # config.ru
29
+ require "digest_fields/rack"
30
+
31
+ use Rack::DigestFields,
32
+ on_partial_content: :raise, # required: :raise | :warn | :skip
33
+ unencoded_digest: true # uses default algorithms (sha-256, sha-512)
34
+ ```
35
+
36
+ With algorithm override:
37
+
38
+ ```ruby
39
+ use Rack::DigestFields,
40
+ on_partial_content: :skip,
41
+ unencoded_digest: {
42
+ algorithms: %w[sha-256],
43
+ on_partial_content: :warn # overrides global for this header
44
+ }
45
+ ```
46
+
47
+ In Rails, insert before `Rack::Sendfile` to ensure the middleware sees the raw response body before any transformation:
48
+
49
+ ```ruby
50
+ config.middleware.insert_before Rack::Sendfile, Rack::DigestFields,
51
+ on_partial_content: :raise,
52
+ unencoded_digest: true
53
+ ```
54
+
55
+ `on_partial_content` is required with no default — `206 Partial Content` responses cannot correctly produce `Unencoded-Digest` (it requires the full unencoded representation). The three behaviours are:
56
+
57
+ | Value | Behaviour |
58
+ |---|---|
59
+ | `:raise` | Raise `Rack::DigestFields::PartialContentError` |
60
+ | `:warn` | Emit a warning and omit the header |
61
+ | `:skip` | Omit the header silently |
62
+
12
63
  ### Library
13
64
 
14
65
  ```ruby
15
- digest = DigestFields.digest(body)
16
- digest.to_s # => "sha-256=:X48E9qOok...:, sha-512=:jas48SD...:"
66
+ DigestFields.digest(body)
67
+ # => "sha-256=:X48E9qOok...:, sha-512=:jas48SD...:"
68
+
69
+ DigestFields.digest(body, algorithms: "sha-256")
70
+ # => "sha-256=:X48E9qOok...:
71
+
72
+ DigestFields.digest(body, algorithms: %w[sha-256 sha-512])
73
+ # => "sha-256=:X48E9qOok...:, sha-512=:jas48SD...:"
74
+ ```
75
+
76
+ ### Custom Algorithms
77
+
78
+ `sha-512` and `sha-256` are supported.
79
+
80
+ The spec's [deprecated hash algorithms](https://www.iana.org/assignments/http-digest-hash-alg/http-digest-hash-alg.xhtml) are intentionally not supported, but you can add your own support if you need to:
81
+
82
+ ```ruby
83
+ # Register a custom algorithm
84
+ DigestFields.algorithms.add("md5", ->(body) { Digest::MD5.base64digest(body) })
17
85
 
18
- digest = DigestFields.digest(body, algorithms: [:sha256])
19
- digest.to_s # => "sha-256=:X48E9qOok...:
86
+ DigestFields.digest(body, algorithms: %w[md5 sha-512])
87
+ # => "md5=:...:, sha-512=:...:, "
20
88
  ```
21
89
 
22
90
  ## Installation
@@ -0,0 +1,27 @@
1
+ module DigestFields
2
+ class Algorithms
3
+ def initialize
4
+ reset!
5
+ end
6
+
7
+ def add(key, callable)
8
+ @registry[key] = callable
9
+ end
10
+
11
+ def reset!
12
+ @registry = {}
13
+ add("sha-256", ->(body) { Digest::SHA256.base64digest(body) })
14
+ add("sha-512", ->(body) { Digest::SHA512.base64digest(body) })
15
+ end
16
+
17
+ def keys
18
+ @registry.keys
19
+ end
20
+
21
+ def fetch(key)
22
+ @registry.fetch(key) do
23
+ raise ArgumentError, "#{key.inspect} not available (try one of: #{@registry.keys.join(", ")})"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,24 +1,9 @@
1
1
  module DigestFields::Digestion
2
- ALGORITHMS = {
3
- sha256: ->(body) { Digest::SHA256.base64digest(body) },
4
- sha512: ->(body) { Digest::SHA512.base64digest(body) }
5
- }
6
-
7
2
  module_function
8
3
 
9
- def compute(body, algorithms: %i[sha256 sha512])
10
- algorithms = [algorithms].flatten
11
-
12
- algorithms.map do |algorithm|
13
- key = algorithm.to_s.sub(/(\d+)$/, '-\1')
14
-
15
- algorithm = ALGORITHMS.fetch(algorithm) do
16
- raise ArgumentError, "#{algorithm.inspect} not available (try one of: #{ALGORITHMS.keys.join(", ")})"
17
- end
18
-
19
- digest = algorithm.call(body)
20
-
21
- "#{key}=:#{digest}:"
4
+ def compute(body, registry:, algorithms:)
5
+ [algorithms].flatten.map do |key|
6
+ "#{key}=:#{registry.fetch(key).call(body)}:"
22
7
  end.join(", ")
23
8
  end
24
9
  end
@@ -0,0 +1,97 @@
1
+ require "rack"
2
+ require "digest_fields"
3
+
4
+ module Rack
5
+ class DigestFields
6
+ class PartialContentError < StandardError; end
7
+
8
+ VALID_ON_PARTIAL_CONTENT = %i[skip warn raise].freeze
9
+ RECOGNISED_HEADER_KEYS = %i[unencoded_digest].freeze
10
+ KNOWN_OPTION_KEYS = (RECOGNISED_HEADER_KEYS + [:on_partial_content]).freeze
11
+
12
+ def initialize(app, **options)
13
+ @app = app
14
+ validate_options!(options)
15
+ @on_partial_content = options.fetch(:on_partial_content)
16
+ @unencoded_digest = parse_unencoded_digest(options[:unencoded_digest])
17
+ end
18
+
19
+ def call(env)
20
+ status, headers, body = @app.call(env)
21
+
22
+ if status == 206
23
+ mode = @unencoded_digest[:on_partial_content] || @on_partial_content
24
+ case mode
25
+ when :skip
26
+ return [status, headers, body]
27
+ when :warn
28
+ Kernel.warn("Rack::DigestFields: Unencoded-Digest cannot be computed for 206 Partial Content")
29
+ return [status, headers, body]
30
+ when :raise
31
+ raise PartialContentError, "Rack::DigestFields: Unencoded-Digest cannot be computed for 206 Partial Content"
32
+ end
33
+ end
34
+
35
+ chunks = []
36
+ body.each { |chunk| chunks << chunk }
37
+ body.close if body.respond_to?(:close)
38
+ buffered = chunks.join
39
+
40
+ digest = ::DigestFields.digest(buffered, algorithms: @unencoded_digest[:algorithms])
41
+ headers = headers.merge("Unencoded-Digest" => digest)
42
+
43
+ [status, headers, [buffered]]
44
+ end
45
+
46
+ private
47
+
48
+ def validate_options!(options)
49
+ unknown = options.keys - KNOWN_OPTION_KEYS
50
+ raise ArgumentError, "Unrecognised options: #{unknown.map(&:inspect).join(", ")}" if unknown.any?
51
+
52
+ unless (options.keys & RECOGNISED_HEADER_KEYS).any?
53
+ raise ArgumentError, "No digest headers configured. Provide at least one of: #{RECOGNISED_HEADER_KEYS.join(", ")}"
54
+ end
55
+
56
+ unless options.key?(:on_partial_content)
57
+ raise ArgumentError, "on_partial_content is required (choose: :skip, :warn, or :raise)"
58
+ end
59
+
60
+ validate_on_partial_content!(options[:on_partial_content], context: "global")
61
+
62
+ if options.key?(:unencoded_digest)
63
+ config = options[:unencoded_digest]
64
+ unless config == true || config.is_a?(Hash)
65
+ raise ArgumentError, "unencoded_digest must be true or a Hash, got #{config.inspect}"
66
+ end
67
+ end
68
+
69
+ return unless options[:unencoded_digest].is_a?(Hash)
70
+
71
+ if options[:unencoded_digest].key?(:on_partial_content)
72
+ validate_on_partial_content!(options[:unencoded_digest][:on_partial_content], context: "unencoded_digest")
73
+ end
74
+
75
+ if options[:unencoded_digest][:algorithms] == []
76
+ raise ArgumentError, "algorithms cannot be empty"
77
+ end
78
+ end
79
+
80
+ def validate_on_partial_content!(value, context:)
81
+ return if VALID_ON_PARTIAL_CONTENT.include?(value)
82
+ raise ArgumentError, "Invalid on_partial_content #{value.inspect} for #{context} (choose: :skip, :warn, or :raise)"
83
+ end
84
+
85
+ def parse_unencoded_digest(config)
86
+ # Algorithms are snapshotted from the registry at middleware init time.
87
+ # Changes to DigestFields.algorithms after initialization are not reflected.
88
+ base_algorithms = ::DigestFields.algorithms.keys
89
+ return {algorithms: base_algorithms} if config == true || config.nil? || config == {}
90
+
91
+ {
92
+ algorithms: config[:algorithms] || base_algorithms,
93
+ on_partial_content: config[:on_partial_content]
94
+ }
95
+ end
96
+ end
97
+ end
@@ -1,3 +1,3 @@
1
1
  module DigestFields
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/digest_fields.rb CHANGED
@@ -1,12 +1,15 @@
1
1
  require "digest"
2
2
 
3
3
  module DigestFields
4
- module_function
4
+ def self.algorithms
5
+ @algorithms ||= Algorithms.new
6
+ end
5
7
 
6
- def digest(*, **)
7
- Digestion.compute(*, **)
8
+ def self.digest(body, algorithms: self.algorithms.keys)
9
+ Digestion.compute(body, registry: self.algorithms, algorithms: algorithms)
8
10
  end
9
11
  end
10
12
 
11
13
  require_relative "digest_fields/version"
14
+ require_relative "digest_fields/algorithms"
12
15
  require_relative "digest_fields/digestion"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digest_fields
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pete Nicholls
@@ -49,7 +49,9 @@ files:
49
49
  - Rakefile
50
50
  - certs/aupajo.pem
51
51
  - lib/digest_fields.rb
52
+ - lib/digest_fields/algorithms.rb
52
53
  - lib/digest_fields/digestion.rb
54
+ - lib/digest_fields/rack.rb
53
55
  - lib/digest_fields/version.rb
54
56
  homepage: https://github.com/aupajo/digest_fields
55
57
  licenses:
metadata.gz.sig CHANGED
Binary file