linzer 0.7.9 → 0.8.0.beta2

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +1 -0
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +63 -0
  5. data/Rakefile +6 -0
  6. data/benchmarks/profile_sign_ed25519.rb +102 -0
  7. data/benchmarks/profile_verify_ed25519.rb +107 -0
  8. data/benchmarks/sign.rb +55 -0
  9. data/benchmarks/verify.rb +68 -0
  10. data/lib/faraday/http_signature/middleware.rb +25 -4
  11. data/lib/linzer/common.rb +53 -23
  12. data/lib/linzer/ed25519.rb +4 -2
  13. data/lib/linzer/helper.rb +34 -15
  14. data/lib/linzer/hmac.rb +14 -12
  15. data/lib/linzer/http/signature_feature.rb +15 -4
  16. data/lib/linzer/http/structured_field.rb +145 -0
  17. data/lib/linzer/http.rb +13 -7
  18. data/lib/linzer/jws.rb +4 -4
  19. data/lib/linzer/key.rb +20 -2
  20. data/lib/linzer/message/adapter/abstract.rb +36 -22
  21. data/lib/linzer/message/adapter/generic/request.rb +1 -0
  22. data/lib/linzer/message/adapter.rb +0 -3
  23. data/lib/linzer/message/field/parser.rb +5 -5
  24. data/lib/linzer/message/field.rb +55 -3
  25. data/lib/linzer/message/overlay.rb +143 -0
  26. data/lib/linzer/message/wrapper.rb +0 -2
  27. data/lib/linzer/message.rb +18 -0
  28. data/lib/linzer/rack.rb +24 -0
  29. data/lib/linzer/rsa_pss.rb +4 -4
  30. data/lib/linzer/signature/context.rb +80 -0
  31. data/lib/linzer/signature/profile/base.rb +43 -0
  32. data/lib/linzer/signature/profile/example.rb +39 -0
  33. data/lib/linzer/signature/profile/web_bot_auth.rb +201 -0
  34. data/lib/linzer/signature/profile.rb +70 -0
  35. data/lib/linzer/signature.rb +147 -32
  36. data/lib/linzer/signer.rb +36 -15
  37. data/lib/linzer/verifier.rb +9 -3
  38. data/lib/linzer/version.rb +1 -1
  39. data/lib/linzer.rb +5 -4
  40. metadata +13 -41
data/lib/linzer/helper.rb CHANGED
@@ -16,12 +16,22 @@ module Linzer
16
16
  #
17
17
  # @param request_or_response [Net::HTTPRequest, Net::HTTPResponse, Rack::Request,
18
18
  # Rack::Response, HTTP::Request] The HTTP message to sign
19
- # @param args [Hash] Keyword arguments
20
- # @option args [Linzer::Key] :key The private key to sign with (required)
21
- # @option args [Array<String>] :components The components to include in the
22
- # signature (required). Example: `%w[@method @path content-type]`
23
- # @option args [String] :label Optional signature label (defaults to "sig1")
24
- # @option args [Hash] :params Additional signature parameters (created, nonce, etc.)
19
+ #
20
+ # @param key [Linzer::Key]
21
+ # The private key to sign with (required)
22
+ #
23
+ # @param components [Array<String>]
24
+ # The components to include in the signature (required).
25
+ # Example: `%w[@method @path content-type]`
26
+ #
27
+ # @param label [String, nil]
28
+ # Optional signature label (defaults to "sig1")
29
+ #
30
+ # @param params [Hash]
31
+ # Additional signature parameters (created, nonce, etc.)
32
+ #
33
+ # @param profile [Symbol, Linzer::Signature::Profile::Base, nil]
34
+ # Optional signing profile
25
35
  #
26
36
  # @return [Object] The original HTTP message with signature headers attached
27
37
  #
@@ -46,17 +56,26 @@ module Linzer
46
56
  # label: "my-sig",
47
57
  # params: { nonce: SecureRandom.hex(16), tag: "my-app" }
48
58
  # )
49
- def sign!(request_or_response, **args)
50
- message = Message.new(request_or_response)
51
- options = {}
59
+ def sign!(request_or_response, key:, components: nil, label: nil, params: {}, profile: nil)
60
+ ctx = Signature::Context.new(
61
+ message: Message.new(request_or_response),
62
+ key: key,
63
+ label: label,
64
+ components: Array(components),
65
+ params: Hash(params)
66
+ )
67
+
68
+ resolved_profile = Signature::Profile.resolve(profile)
69
+ resolved_profile&.apply(ctx)
52
70
 
53
- label = args[:label]
54
- options[:label] = label if label
55
- options.merge!(args.fetch(:params, {}))
71
+ signature = Linzer::Signer.sign(
72
+ ctx.key,
73
+ ctx.message,
74
+ ctx.components,
75
+ ctx.params
76
+ )
56
77
 
57
- key = args.fetch(:key)
58
- signature = Linzer::Signer.sign(key, message, args.fetch(:components), options)
59
- message.attach!(signature)
78
+ ctx.message.attach!(signature)
60
79
  end
61
80
 
62
81
  # Verifies a signed HTTP request or response.
data/lib/linzer/hmac.rb CHANGED
@@ -54,18 +54,6 @@ module Linzer
54
54
  OpenSSL.secure_compare(signature, sign(data))
55
55
  end
56
56
 
57
- # HMAC keys can always sign (they contain the secret).
58
- # @return [Boolean] true if key material is present
59
- def private?
60
- !material.nil?
61
- end
62
-
63
- # HMAC keys are symmetric, not public/private.
64
- # @return [Boolean] always false for HMAC keys
65
- def public?
66
- false
67
- end
68
-
69
57
  # Returns a safe string representation that doesn't leak the secret.
70
58
  #
71
59
  # The key material is intentionally excluded from the output to prevent
@@ -82,6 +70,20 @@ module Linzer
82
70
  oid = Digest::SHA2.hexdigest(object_id.to_s)[48..63]
83
71
  "#<%s:0x%s %s>" % [self.class, oid, vars.join(", ")]
84
72
  end
73
+
74
+ private
75
+
76
+ # HMAC keys can always sign (they contain the secret).
77
+ # @return [Boolean] true if key material is present
78
+ def compute_private?
79
+ !material.nil?
80
+ end
81
+
82
+ # HMAC keys are symmetric, not public/private.
83
+ # @return [Boolean] always false for HMAC keys
84
+ def compute_public?
85
+ false
86
+ end
85
87
  end
86
88
  end
87
89
  end
@@ -53,12 +53,17 @@ module Linzer
53
53
  # @param covered_components [Array<String>] Components to include
54
54
  # in the signature. Defaults to `@method`, `@request-target`,
55
55
  # `@authority`, and `date`.
56
+ # @param profile [Symbol, Linzer::Signature::Profile::Base, nil]
57
+ # Optional signing profile used when generating signatures.
58
+ # When provided, the profile may supply default covered components
59
+ # and signature parameters.
56
60
  #
57
61
  # @raise [HTTP::Error] If key is nil or invalid
58
- def initialize(key:, params: {}, covered_components: default_components)
62
+ def initialize(key:, params: {}, covered_components: default_components, profile: nil)
59
63
  @fields = Array(covered_components)
60
64
  @key = validate_key(key)
61
65
  @params = Hash(params)
66
+ @profile = profile
62
67
  end
63
68
 
64
69
  # @return [Array<String>] The components to include in signatures
@@ -67,6 +72,10 @@ module Linzer
67
72
  # @return [Hash] Additional signature parameters
68
73
  attr_reader :params
69
74
 
75
+ # @return [Linzer::Signature::Profile::Base, Symbol, nil]
76
+ # Optional signing profile used during signature generation
77
+ attr_reader :profile
78
+
70
79
  # Wraps an outgoing request to add signature headers.
71
80
  #
72
81
  # Called automatically by http.rb for each request.
@@ -74,9 +83,11 @@ module Linzer
74
83
  # @param request [HTTP::Request] The outgoing request
75
84
  # @return [HTTP::Request] The request with signature headers added
76
85
  def wrap_request(request)
77
- message = Linzer::Message.new(request)
78
- signature = Linzer.sign(key, message, fields, **params)
79
- request.headers.merge!(signature.to_h)
86
+ Linzer.sign! request,
87
+ key: key,
88
+ components: fields,
89
+ params: params,
90
+ profile: profile
80
91
  request
81
92
  end
82
93
 
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linzer
4
+ module HTTP
5
+ # Utilities for serializing HTTP Structured Fields as defined in RFC 8941.
6
+ #
7
+ # This module currently provides helpers for serializing HTTP Message
8
+ # Signature parameters as used by RFC 9421.
9
+ #
10
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
11
+ # @see https://www.rfc-editor.org/rfc/rfc9421 RFC 9421
12
+ module StructuredField
13
+ InnerList = Starry::InnerList
14
+ Item = Starry::Item
15
+
16
+ # Parses an RFC 8941 Structured Field Dictionary.
17
+ #
18
+ # @param str [String] the serialized dictionary value
19
+ # @param field_name [String, nil] optional field name for contextual errors
20
+ # @return [Hash<String, Object>] parsed structured field dictionary
21
+ # @raise [Linzer::Error] if the field cannot be parsed
22
+ #
23
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
24
+ def self.parse_dictionary(str, field_name: nil)
25
+ Starry.parse_dictionary(str)
26
+ # rescue Starry::ParseError => ex
27
+ # https://github.com/takemar/starry/pull/4
28
+ rescue => ex
29
+ cannot_parse = "Cannot parse %sfield!"
30
+ raise Error,
31
+ cannot_parse % [field_name ? "\"#{field_name}\" " : nil],
32
+ cause: ex
33
+ end
34
+
35
+ # Parses an RFC 8941 Structured Field Item.
36
+ #
37
+ # @param item [String] serialized structured field item
38
+ # @return [Starry::Item] parsed structured field item
39
+ # @raise [Linzer::Error] if the item is invalid or unparseable
40
+ #
41
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
42
+ def self.parse_item(item)
43
+ Starry.parse_item(item)
44
+ # rescue Starry::ParseError => ex
45
+ # https://github.com/takemar/starry/pull/4
46
+ rescue => ex
47
+ raise Error, "Invalid/unparseable HTTP field item", cause: ex
48
+ end
49
+
50
+ # Parses an RFC 8941 Structured Field List.
51
+ #
52
+ # @param list [String] serialized structured field list
53
+ # @return [Array<Object>] parsed structured field list members
54
+ # @raise [Linzer::Error] if the list is invalid or unparseable
55
+ #
56
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
57
+ def self.parse_list(list)
58
+ Starry.parse_list(list)
59
+ # rescue Starry::ParseError => ex
60
+ # https://github.com/takemar/starry/pull/4
61
+ rescue => ex
62
+ raise Error, "Invalid/unparseable HTTP field list", cause: ex
63
+ end
64
+
65
+ # Serializes a dictionary into RFC 8941 Structured Field format.
66
+ #
67
+ # @param hsh [Hash] structured field dictionary
68
+ # @return [String] serialized structured field dictionary
69
+ # @raise [Linzer::Error] if the dictionary cannot be serialized
70
+ #
71
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
72
+ def self.serialize_dictionary(hsh)
73
+ Starry.serialize_dictionary(hsh)
74
+ # rescue Starry::SerializeError => ex
75
+ # https://github.com/takemar/starry/pull/4
76
+ rescue => ex
77
+ raise Error, ex.message, cause: ex
78
+ end
79
+
80
+ # Serializes a list into RFC 8941 Structured Field format.
81
+ #
82
+ # @param arr [Array] structured field list members
83
+ # @return [String] serialized structured field list
84
+ # @raise [Linzer::Error] if the list cannot be serialized
85
+ #
86
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
87
+ def self.serialize_list(arr)
88
+ Starry.serialize_list(arr)
89
+ # rescue Starry::SerializeError => ex
90
+ # https://github.com/takemar/starry/pull/4
91
+ rescue => ex
92
+ raise Error, ex.message, cause: ex
93
+ end
94
+
95
+ # Serializes an object into RFC 8941 Structured Field format.
96
+ #
97
+ # @param obj [Object] structured field value
98
+ # @return [String] serialized structured field value
99
+ # @raise [Linzer::Error] if the object cannot be serialized
100
+ #
101
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
102
+ def self.serialize(obj)
103
+ Starry.serialize(obj)
104
+ rescue Starry::SerializeError => ex
105
+ raise Error, ex.message, cause: ex
106
+ end
107
+
108
+ # Serializes a Structured Field Item into RFC 8941 format.
109
+ #
110
+ # This helper serializes a single Structured Field Item, such as
111
+ # a string, integer, token, boolean, date, byte sequence, or
112
+ # an already constructed {Starry::Item}.
113
+ #
114
+ # @param item [Object] structured field item value
115
+ # @return [String] serialized structured field item
116
+ # @raise [Linzer::Error] if the item cannot be serialized
117
+ #
118
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
119
+ def self.serialize_item(item)
120
+ Starry.serialize_item(item)
121
+ rescue Starry::SerializeError => ex
122
+ raise Error, ex.message, cause: ex
123
+ end
124
+
125
+ # Serializes Structured Field Parameters into RFC 8941 format.
126
+ #
127
+ # Parameters are serialized according to the Structured Fields
128
+ # parameter syntax defined in RFC 8941 Section 3.1.2.
129
+ #
130
+ # @param parameters [Hash{String,Symbol => Object}] parameter names
131
+ # and values
132
+ # @return [String] serialized structured field parameters
133
+ # @raise [Linzer::Error] if the parameters cannot be serialized
134
+ #
135
+ # @see https://www.rfc-editor.org/rfc/rfc8941 RFC 8941
136
+ def self.serialize_parameters(parameters)
137
+ Starry.serialize_parameters(parameters)
138
+ # rescue Starry::SerializeError => ex
139
+ # https://github.com/takemar/starry/pull/4
140
+ rescue => ex
141
+ raise Error, ex.message, cause: ex
142
+ end
143
+ end
144
+ end
145
+ end
data/lib/linzer/http.rb CHANGED
@@ -100,12 +100,18 @@ module Linzer
100
100
 
101
101
  headers = build_headers(options[:headers] || {})
102
102
  request = build_request(verb, uri, headers)
103
- message = Linzer::Message.new(request)
104
103
  components = options[:covered_components] || default_components
105
104
  params = options[:params] || {}
106
- signature = Linzer.sign(key, message, components, **params)
107
105
 
108
- do_request(http, uri, verb, options[:data], signature, headers)
106
+ Linzer.sign! request,
107
+ key: key,
108
+ components: components,
109
+ params: params,
110
+ profile: options[:profile]
111
+
112
+ signature_headers = request.each_header.to_h.slice("signature-input", "signature")
113
+
114
+ do_request(http, uri, verb, options[:data], signature_headers, headers)
109
115
  end
110
116
 
111
117
  # Returns the default covered components for signing.
@@ -180,19 +186,19 @@ module Linzer
180
186
  # @param uri [String] the request URI
181
187
  # @param verb [Symbol] the HTTP method
182
188
  # @param data [String, nil] the request body
183
- # @param signature [Linzer::Signature] the generated signature
189
+ # @param signature_headers [Hash] the generated signature headers
184
190
  # @param headers [Hash] request headers
185
191
  # @return [Net::HTTPResponse] the response
186
192
  # @raise [Linzer::Error] if a body is required but not provided
187
- def do_request(http, uri, verb, data, signature, headers)
193
+ def do_request(http, uri, verb, data, signature_headers, headers)
188
194
  if with_body?(verb)
189
195
  if !data
190
196
  missed_body = "Missing request body on HTTP request: '#{verb.upcase}'"
191
197
  raise Linzer::Error, missed_body
192
198
  end
193
- http.public_send(verb, uri, data, headers.merge(signature.to_h))
199
+ http.public_send(verb, uri, data, headers.merge(signature_headers))
194
200
  else
195
- http.public_send(verb, uri, headers.merge(signature.to_h))
201
+ http.public_send(verb, uri, headers.merge(signature_headers))
196
202
  end
197
203
  end
198
204
  end
data/lib/linzer/jws.rb CHANGED
@@ -101,18 +101,18 @@ module Linzer
101
101
  algo.verify(data: data, signature: signature, verification_key: verify_key)
102
102
  end
103
103
 
104
+ private
105
+
104
106
  # @return [Boolean] true if this key can verify signatures
105
- def public?
107
+ def compute_public?
106
108
  !!verify_key
107
109
  end
108
110
 
109
111
  # @return [Boolean] true if this key can create signatures
110
- def private?
112
+ def compute_private?
111
113
  !!signing_key
112
114
  end
113
115
 
114
- private
115
-
116
116
  # Resolves the appropriate JWT algorithm implementation.
117
117
  # @return [JWT::JWA::SigningAlgorithm] The algorithm implementation
118
118
  # @raise [Error] If the algorithm cannot be determined
data/lib/linzer/key.rb CHANGED
@@ -38,6 +38,8 @@ module Linzer
38
38
  @material = material
39
39
  @params = Hash(params).clone.freeze
40
40
  validate
41
+ @is_private = compute_private?
42
+ @is_public = compute_public?
41
43
  freeze
42
44
  end
43
45
 
@@ -84,14 +86,14 @@ module Linzer
84
86
  #
85
87
  # @return [Boolean] true if the key contains public key material
86
88
  def public?
87
- material.public?
89
+ @is_public
88
90
  end
89
91
 
90
92
  # Checks if this key can be used for signing.
91
93
  #
92
94
  # @return [Boolean] true if the key contains private key material
93
95
  def private?
94
- material.private?
96
+ @is_private
95
97
  end
96
98
 
97
99
  private
@@ -102,6 +104,22 @@ module Linzer
102
104
  !material.nil? or raise Error.new "Invalid key. No key material provided."
103
105
  end
104
106
 
107
+ # Computes whether the key contains private key material.
108
+ # Override in subclasses where the OpenSSL key object does not
109
+ # respond to +private?+ (e.g. Ed25519, RSA-PSS).
110
+ # @return [Boolean]
111
+ def compute_private?
112
+ material.respond_to?(:private?) ? material.private? : false
113
+ end
114
+
115
+ # Computes whether the key contains public key material.
116
+ # Override in subclasses where the OpenSSL key object does not
117
+ # respond to +public?+ (e.g. Ed25519, RSA-PSS).
118
+ # @return [Boolean]
119
+ def compute_public?
120
+ material.respond_to?(:public?) ? material.public? : false
121
+ end
122
+
105
123
  # Validates that a digest algorithm is configured.
106
124
  # @raise [Error] If no digest algorithm is set
107
125
  def validate_digest
@@ -70,7 +70,7 @@ module Linzer
70
70
  # @example With structured field parameter
71
71
  # adapter['"example-dict";key="a"'] # => "1"
72
72
  def [](field)
73
- field_id = field.is_a?(FieldId) ? field : parse_field_name(field)
73
+ field_id = (field.is_a?(FieldId) || field.is_a?(Field::FastIdentifier)) ? field : parse_field_name(field)
74
74
  return nil if field_id.nil? || field_id.item.nil?
75
75
  retrieve(field_id.item, field_id.derived? ? :derived : :field)
76
76
  end
@@ -97,26 +97,35 @@ module Linzer
97
97
  # Attaches a signature to the underlying HTTP message.
98
98
  #
99
99
  # @param signature [Signature] The signature to attach
100
+ # @param additional_headers [#each]
101
+ # Additional headers to attach after signature processing.
102
+ # Header values overwrite existing values with the same field name.
103
+ #
100
104
  # @return [Object] The underlying HTTP message
101
- def attach!(signature)
105
+ def attach!(signature, additional_headers: {})
102
106
  signature_headers = signature.to_h
103
107
 
104
- unless has_signature?
108
+ if !has_signature?
105
109
  signature_headers.each { |h, v| set_header!(h, v) }
106
- return @operation
110
+ else
111
+ begin
112
+ signature_headers.each do |hdr, value|
113
+ merged = HTTP::StructuredField.parse_dictionary(String(header(hdr)))
114
+ merged.merge!(HTTP::StructuredField.parse_dictionary(value))
115
+ set_header!(hdr, HTTP::StructuredField.serialize_dictionary(merged))
116
+ end
117
+ rescue Error => ex
118
+ raise Error,
119
+ "Cannot attach signature, invalid signature header(s)!",
120
+ cause: ex
121
+ end
107
122
  end
108
123
 
109
- signature_headers.each do |hdr, value|
110
- merged = Starry.parse_dictionary(String(header(hdr)))
111
- merged.merge!(Starry.parse_dictionary(value))
112
- set_header!(hdr, Starry.serialize_dictionary(merged))
124
+ if !additional_headers.empty?
125
+ additional_headers.each { |h, v| set_header!(h, v) }
113
126
  end
114
127
 
115
128
  @operation
116
- rescue Starry::ParseError => e
117
- raise Error,
118
- "Cannot attach signature, invalid signature header(s)!",
119
- cause: e
120
129
  end
121
130
 
122
131
  private
@@ -190,18 +199,21 @@ module Linzer
190
199
  # @param method [Symbol] +:derived+ or +:field+
191
200
  # @return [String, Integer, nil] the component value
192
201
  def retrieve(name, method)
193
- if !name.parameters.empty?
194
- valid_params = validate_parameters(name, method)
195
- return nil if !valid_params
196
- end
202
+ # Fast path: no parameters means no special handling needed
203
+ return send(method, name) if name.parameters.empty?
204
+
205
+ valid_params = validate_parameters(name, method)
206
+ return nil if !valid_params
197
207
 
198
208
  has_req = name.parameters["req"]
199
209
  has_sf = name.parameters["sf"] || name.parameters.key?("key")
200
210
  has_bs = name.parameters["bs"]
201
211
 
202
212
  if has_req
203
- name.parameters.delete("req")
204
- return req(name, method)
213
+ request_field =
214
+ HTTP::StructuredField::Item.new(name.value,
215
+ name.parameters.except("req"))
216
+ return req(request_field, method)
205
217
  end
206
218
 
207
219
  value = send(method, name)
@@ -222,14 +234,16 @@ module Linzer
222
234
  # @return [String] the serialized structured field value
223
235
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.1.1
224
236
  def sf(value, key = nil)
225
- dict = Starry.parse_dictionary(value)
237
+ dict = HTTP::StructuredField.parse_dictionary(value)
226
238
 
227
239
  if key
228
240
  obj = dict[key]
229
- Starry.serialize(obj.is_a?(Starry::InnerList) ? [obj] : obj)
241
+ HTTP::StructuredField.serialize(obj.is_a?(HTTP::StructuredField::InnerList) ? [obj] : obj)
230
242
  else
231
- Starry.serialize(dict)
243
+ HTTP::StructuredField.serialize(dict)
232
244
  end
245
+ rescue Error => _ex
246
+ nil
233
247
  end
234
248
 
235
249
  # Binary-wraps a field value as a byte sequence.
@@ -238,7 +252,7 @@ module Linzer
238
252
  # @return [String] the serialized byte sequence
239
253
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-2.1.3
240
254
  def bs(value)
241
- Starry.serialize(value.encode(Encoding::ASCII_8BIT))
255
+ HTTP::StructuredField.serialize(value.encode(Encoding::ASCII_8BIT))
242
256
  end
243
257
 
244
258
  # Retrieves a trailer field value.
@@ -72,6 +72,7 @@ module Linzer
72
72
  end
73
73
 
74
74
  def query_param(uri_query, name)
75
+ return nil if !uri_query
75
76
  param_name = name.parameters["name"]
76
77
  return nil if !param_name
77
78
  decoded_param_name = URI.decode_uri_component(param_name)
@@ -3,9 +3,6 @@
3
3
  require_relative "adapter/abstract"
4
4
  require_relative "adapter/generic/request"
5
5
  require_relative "adapter/generic/response"
6
- require_relative "adapter/rack/common"
7
- require_relative "adapter/rack/request"
8
- require_relative "adapter/rack/response"
9
6
  require_relative "adapter/net_http/request"
10
7
  require_relative "adapter/net_http/response"
11
8
 
@@ -24,15 +24,15 @@ module Linzer
24
24
  def parse(field_name)
25
25
  case
26
26
  when field_name.match?(/";/), field_name.start_with?('"')
27
- Starry.parse_item(field_name)
27
+ HTTP::StructuredField.parse_item(field_name)
28
28
  when field_name.match?(/;/)
29
29
  parse_unserialized_input(field_name)
30
30
  when field_name.start_with?("@"), field_name.match?(/^[a-z]/)
31
- Starry.parse_item(Starry.serialize(field_name))
31
+ HTTP::StructuredField.parse_item(HTTP::StructuredField.serialize(field_name))
32
32
  else
33
33
  raise Error, "Invalid component identifier: '#{field_name}'!"
34
34
  end
35
- rescue Starry::ParseError => ex
35
+ rescue Error => ex
36
36
  parse_error = "Failed to parse component identifier: '#{field_name}'!"
37
37
  raise Error, parse_error, cause: ex
38
38
  end
@@ -49,7 +49,7 @@ module Linzer
49
49
  # @return [Starry::Item] the parsed item with parameters
50
50
  def parse_unserialized_input(field_name)
51
51
  field, *raw_params = field_name.split(";")
52
- item = Starry.parse_item(Starry.serialize(field))
52
+ item = HTTP::StructuredField.parse_item(HTTP::StructuredField.serialize(field))
53
53
  item.parameters = collect_parameters(raw_params)
54
54
  item
55
55
  end
@@ -67,7 +67,7 @@ module Linzer
67
67
  {param => true}
68
68
  else
69
69
  Hash[*tokens.first(2)] # e.g.: ";key=\"foo\""
70
- .transform_values! { |v| Starry.parse_item(v).value }
70
+ .transform_values! { |v| HTTP::StructuredField.parse_item(v).value }
71
71
  end
72
72
  end
73
73
  params.reduce({}, :merge)
@@ -34,7 +34,7 @@ module Linzer
34
34
  # @raise [Error] If the component identifier is invalid
35
35
  def serialize
36
36
  raise Error, "Invalid component identifier: '#{field_name}'!" unless item
37
- Starry.serialize(@item)
37
+ @serialized || HTTP::StructuredField.serialize(@item)
38
38
  end
39
39
  end
40
40
 
@@ -54,6 +54,29 @@ module Linzer
54
54
 
55
55
  Identifier.include Message::Field::IdentifierMethods
56
56
 
57
+ # Lightweight FieldId for simple components (no parameters).
58
+ # Bypasses Starry parsing entirely. Duck-types with Identifier
59
+ # for use in the adapter's [] method.
60
+ # @api private
61
+ class FastIdentifier
62
+ def initialize(serialized, item)
63
+ @field_name = serialized
64
+ @item = item
65
+ @serialized = serialized
66
+ freeze
67
+ end
68
+
69
+ attr_reader :field_name, :item
70
+
71
+ def derived?
72
+ @item.value.start_with?("@")
73
+ end
74
+
75
+ def serialize
76
+ @serialized
77
+ end
78
+ end
79
+
57
80
  class Identifier
58
81
  class << self
59
82
  # Serializes a single component identifier.
@@ -70,13 +93,42 @@ module Linzer
70
93
  components.map(&method(:serialize))
71
94
  end
72
95
 
96
+ # Serializes an array of component identifiers, returning both
97
+ # the serialized strings and the FieldId objects for reuse.
98
+ # @param components [Array<String>] Component names
99
+ # @return [Array(Array<String>, Array<Identifier>)] Serialized strings and FieldId objects
100
+ def serialize_components_with_field_ids(components)
101
+ serialized = Array.new(components.size)
102
+ field_ids = Array.new(components.size)
103
+
104
+ components.each_with_index do |c, i|
105
+ if c.include?(";") || c.include?('"')
106
+ # Complex component with parameters or already serialized:
107
+ # fall back to full Starry parsing
108
+ fid = new(field_name: c)
109
+ field_ids[i] = fid
110
+ serialized[i] = fid.serialize
111
+ else
112
+ # Simple component (e.g. "@method", "content-type"):
113
+ # build the Item and serialized string directly,
114
+ # bypassing Starry.parse_item + Starry.serialize
115
+ quoted = "\"#{c}\""
116
+ item = HTTP::StructuredField::Item.new(c, {})
117
+ field_ids[i] = FastIdentifier.new(quoted, item)
118
+ serialized[i] = quoted
119
+ end
120
+ end
121
+
122
+ [serialized, field_ids]
123
+ end
124
+
73
125
  # Deserializes component identifiers back to names.
74
126
  # @param components [Array<String>] Serialized identifiers
75
127
  # @return [Array<String>] Component names
76
128
  def deserialize_components(components)
77
129
  components.map do |c|
78
- item = Starry.parse_item(c)
79
- item.parameters.empty? ? item.value : Starry.serialize(item)
130
+ item = HTTP::StructuredField.parse_item(c)
131
+ item.parameters.empty? ? item.value : HTTP::StructuredField.serialize(item)
80
132
  end
81
133
  end
82
134
  end