excon 1.10.0 → 1.11.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: a8f339eac15303582618d7c092e0c31facc11e717477d11676ddf1cece1f4df8
4
- data.tar.gz: f1225a0e544fb64eed9411d5f0c5028103a853e07acfa5df5b9c8d1057101297
3
+ metadata.gz: a5b23fd4372baad22a291604ac1177dede89a9e4a8b9caa1aa86019f4893b98d
4
+ data.tar.gz: 54bb6046bf8e2359a75e6720b9d8d0ffbdb41dbeeea3a483b78d0e58a2653119
5
5
  SHA512:
6
- metadata.gz: 37951c154ffa841570e55ec4bd9bd6894ccd4617cbf51f03e2606d341defde41e114d85b78af7cb67ffc9f46ba4fb8ca59398dcd219f4aeb0267ee79f5a20649
7
- data.tar.gz: bf5f8bb104fcdcc6fe87d8f9a221775214af61b81df535f603ff1c1c47b44b5bfd441aef1bb35ede8761cdb2badd5ff52be93ecd2a3639a4809cf0fc3371f383
6
+ metadata.gz: 6032a35accee4a3c447c61b2a1d8bac83352b6fd3c2eb18171f59942614983d5b17bcc18f55e051e4280008ff326acc65f93134f2cfb88d6ce18f604dd51b5ac
7
+ data.tar.gz: 8dfd5595f225591adf29b9d4cba4740825e75b575d894c4a5ebdb3cad468fadefa9b980ca68e7a443a23cb6e04ef72cf457855c760e93b6894f74327674c6b17
data/README.md CHANGED
@@ -403,6 +403,37 @@ end
403
403
 
404
404
  By default stubs are shared globally, to make stubs unique to each thread, use `Excon.defaults[:stubs] = :local`.
405
405
 
406
+ ### Checking that stubs were called
407
+
408
+ Excon counts the requests each stub answers, so a test can check that the stubs it set up were actually used, instead of passing silently when the request is never made.
409
+
410
+ ```ruby
411
+ stub = Excon.stub({ :method => :get, :path => '/users' }, { :body => '[]', :status => 200 })
412
+
413
+ Excon.stub_call_count(stub) # => 0
414
+ Excon.get('http://example.com/users', :mock => true)
415
+ Excon.stub_call_count(stub) # => 1
416
+ ```
417
+
418
+ `Excon.verify_stubs` raises `Excon::Error::StubNotCalled`, listing the request params of each stub that answered no requests. With no arguments it checks every stub in `Excon.stubs`; pass stubs to check only those, for instance to leave out a catch-all fallback stub that some tests never use.
419
+
420
+ ```ruby
421
+ Excon.verify_stubs # check every stub
422
+ Excon.verify_stubs(stub) # check only this stub
423
+ ```
424
+
425
+ For example, with RSpec:
426
+
427
+ ```ruby
428
+ config.after(:each) do
429
+ Excon.verify_stubs
430
+ ensure
431
+ Excon.stubs.clear
432
+ end
433
+ ```
434
+
435
+ A stub's count is kept with the stub object `Excon.stub` returns (or `Excon.stub_for` finds), so two identical stubs are counted separately. Counts are only kept for stubs still in `Excon.stubs`, so don't rely on the count of a stub after removing it. Counts follow `Excon.defaults[:stubs]`, so `:local` stubs are counted per thread.
436
+
406
437
  ## Instrumentation
407
438
 
408
439
  Excon calls can be timed using the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API.
data/data/cacert.pem CHANGED
@@ -1,7 +1,7 @@
1
1
  ##
2
2
  ## Bundle of CA Root Certificates
3
3
  ##
4
- ## Certificate data from Mozilla as of: Tue Oct 20 13:32:40 2026 GMT
4
+ ## Certificate data from Mozilla as of: Thu Nov 5 04:12:01 2026 GMT
5
5
  ##
6
6
  ## Find updated versions here: https://curl.se/docs/caextract.html
7
7
  ##
@@ -16,7 +16,7 @@
16
16
  ## Configure this file as the SSLCACertificateFile.
17
17
  ##
18
18
  ## Conversion done with mk-ca-bundle.pl version 1.35.
19
- ## SHA256: beb7e6dfe6499926e52c075c27bcfbe4c957f8609c575b3860273ae2806f63eb
19
+ ## SHA256: 3ec2b4d240dc12c747eaf450061db84af757c483bbda302f9e29ceb5d70be510
20
20
  ##
21
21
 
22
22
 
@@ -547,7 +547,11 @@ module Excon
547
547
  end
548
548
 
549
549
  def raise_socket_error(error)
550
- if error.message.include?('certificate verify failed')
550
+ # already a Socket error (e.g. from a proxy handshake): wrapping it
551
+ # again would add nothing but its class name to the message
552
+ if error.is_a?(Excon::Error::Socket)
553
+ raise(error)
554
+ elsif error.message.include?('certificate verify failed')
551
555
  raise(Excon::Errors::CertificateError.new(error))
552
556
  else
553
557
  raise(Excon::Errors::SocketError.new(error))
@@ -617,7 +621,12 @@ module Excon
617
621
  when Hash
618
622
  # no processing needed
619
623
  when String, URI
620
- uri = @data[:proxy].is_a?(String) ? URI.parse(@data[:proxy]) : @data[:proxy]
624
+ uri = if @data[:proxy].is_a?(String)
625
+ uri_parser = @data[:uri_parser] || Excon.defaults[:uri_parser]
626
+ uri_parser.parse(@data[:proxy])
627
+ else
628
+ @data[:proxy]
629
+ end
621
630
  @data[:proxy] = {
622
631
  :host => uri.host,
623
632
  :hostname => uri.hostname,
data/lib/excon/error.rb CHANGED
@@ -5,6 +5,7 @@ module Excon
5
5
  @default_status_error = :HTTPStatus
6
6
 
7
7
  class StubNotFound < Error; end
8
+ class StubNotCalled < Error; end
8
9
  class InvalidStub < Error; end
9
10
  class Warning < Error; end
10
11
 
@@ -12,9 +13,14 @@ module Excon
12
13
  class Socket < Error
13
14
  attr_reader :socket_error
14
15
 
16
+ # socket_error is the error to wrap, or a message for an error that
17
+ # wraps none (whose socket_error is then itself)
15
18
  def initialize(socket_error = Excon::Error.new)
16
19
  if is_a?(Certificate) || is_a?(Excon::Errors::CertificateError)
17
20
  super
21
+ elsif socket_error.is_a?(String)
22
+ super
23
+ @socket_error = self
18
24
  else
19
25
  super("#{socket_error.message} (#{socket_error.class})")
20
26
  set_backtrace(socket_error.backtrace)
@@ -52,7 +58,9 @@ or:
52
58
  class Timeout < Error; end
53
59
  class ResponseParse < Error; end
54
60
 
55
- class ProxyConnectionError < Error
61
+ # Raised when a proxy refuses to open a CONNECT tunnel. A Socket error, so
62
+ # it is retried and rescued as one; socket_error returns the error itself.
63
+ class ProxyConnectionError < Socket
56
64
  attr_reader :request, :response
57
65
 
58
66
  def initialize(msg, request = nil, response = nil)
@@ -62,6 +70,10 @@ or:
62
70
  end
63
71
  end
64
72
 
73
+ # Raised when a SOCKS5 proxy handshake fails. A Socket error, so it is
74
+ # retried and rescued as one; socket_error returns the error itself.
75
+ class SOCKS5Error < Socket; end
76
+
65
77
  class ProxyParse < Error; end
66
78
  class TooManyRedirects < Error; end
67
79
 
@@ -26,6 +26,8 @@ module Excon
26
26
  end
27
27
 
28
28
  if (stub = Excon.stub_for(datum))
29
+ # count before calling a block stub, so one that raises still counts
30
+ Excon.record_stub_call(stub)
29
31
  datum[:remote_ip] ||= '127.0.0.1'
30
32
  datum[:response] = {
31
33
  :body => '',
@@ -126,32 +126,20 @@ module Excon
126
126
  end
127
127
  end
128
128
 
129
+ # without a :response_block (or when :expects would fail), buffer the body
130
+ response_block ||= lambda { |chunk, _remaining, _total| datum[:response][:body] << chunk }
131
+
129
132
  if transfer_encoding_chunked
130
- if response_block
131
- while (chunk_size = socket.readline.chomp!.to_i(16)) > 0
132
- while chunk_size > 0
133
- chunk = socket.read(chunk_size) || raise(EOFError)
134
- chunk_size -= chunk.bytesize
135
- response_block.call(chunk, nil, nil)
136
- end
137
- new_line_size = 2 # 2 == "\r\n".length
138
- while new_line_size > 0
139
- chunk = socket.read(new_line_size) || raise(EOFError)
140
- new_line_size -= chunk.length
141
- end
133
+ while (chunk_size = socket.readline.chomp!.to_i(16)) > 0
134
+ while chunk_size > 0
135
+ chunk = socket.read(chunk_size) || raise(EOFError)
136
+ chunk_size -= chunk.bytesize
137
+ response_block.call(chunk, nil, nil)
142
138
  end
143
- else
144
- while (chunk_size = socket.readline.chomp!.to_i(16)) > 0
145
- while chunk_size > 0
146
- chunk = socket.read(chunk_size) || raise(EOFError)
147
- chunk_size -= chunk.bytesize
148
- datum[:response][:body] << chunk
149
- end
150
- new_line_size = 2 # 2 == "\r\n".length
151
- while new_line_size > 0
152
- chunk = socket.read(new_line_size) || raise(EOFError)
153
- new_line_size -= chunk.length
154
- end
139
+ new_line_size = 2 # 2 == "\r\n".length
140
+ while new_line_size > 0
141
+ chunk = socket.read(new_line_size) || raise(EOFError)
142
+ new_line_size -= chunk.length
155
143
  end
156
144
  end
157
145
  parse_headers(socket, datum) # merge trailers into headers
@@ -161,28 +149,14 @@ module Excon
161
149
  end
162
150
 
163
151
  if (remaining = content_length)
164
- if response_block
165
- while remaining > 0
166
- chunk = socket.read([datum[:chunk_size], remaining].min) || raise(EOFError)
167
- response_block.call(chunk, [remaining - chunk.bytesize, 0].max, content_length)
168
- remaining -= chunk.bytesize
169
- end
170
- else
171
- while remaining > 0
172
- chunk = socket.read([datum[:chunk_size], remaining].min) || raise(EOFError)
173
- datum[:response][:body] << chunk
174
- remaining -= chunk.bytesize
175
- end
152
+ while remaining > 0
153
+ chunk = socket.read([datum[:chunk_size], remaining].min) || raise(EOFError)
154
+ response_block.call(chunk, [remaining - chunk.bytesize, 0].max, content_length)
155
+ remaining -= chunk.bytesize
176
156
  end
177
157
  else
178
- if response_block
179
- while (chunk = socket.read(datum[:chunk_size]))
180
- response_block.call(chunk, nil, nil)
181
- end
182
- else
183
- while (chunk = socket.read(datum[:chunk_size]))
184
- datum[:response][:body] << chunk
185
- end
158
+ while (chunk = socket.read(datum[:chunk_size]))
159
+ response_block.call(chunk, nil, nil)
186
160
  end
187
161
  end
188
162
  end
data/lib/excon/socks5.rb CHANGED
@@ -80,7 +80,7 @@ module Excon
80
80
  version, chosen_method = response.unpack('CC')
81
81
 
82
82
  if version != SOCKS5_VERSION
83
- raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned invalid version: #{version}"))
83
+ raise Excon::Error::SOCKS5Error, "SOCKS5 proxy returned invalid version: #{version}"
84
84
  end
85
85
 
86
86
  case chosen_method
@@ -88,13 +88,13 @@ module Excon
88
88
  # No authentication required
89
89
  when SOCKS5_AUTH_USERNAME_PASSWORD
90
90
  unless @proxy_user && @proxy_pass
91
- raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy requires authentication but no credentials provided'))
91
+ raise Excon::Error::SOCKS5Error, 'SOCKS5 proxy requires authentication but no credentials provided'
92
92
  end
93
93
  socks5_username_password_auth
94
94
  when SOCKS5_NO_ACCEPTABLE_AUTH
95
- raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy: no acceptable authentication methods'))
95
+ raise Excon::Error::SOCKS5Error, 'SOCKS5 proxy: no acceptable authentication methods'
96
96
  else
97
- raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy: unsupported authentication method #{chosen_method}"))
97
+ raise Excon::Error::SOCKS5Error, "SOCKS5 proxy: unsupported authentication method #{chosen_method}"
98
98
  end
99
99
  end
100
100
 
@@ -114,14 +114,14 @@ module Excon
114
114
  _, status = response.unpack('CC')
115
115
 
116
116
  unless status == 0x00
117
- raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy authentication failed'))
117
+ raise Excon::Error::SOCKS5Error, 'SOCKS5 proxy authentication failed'
118
118
  end
119
119
  end
120
120
 
121
121
  # Request connection to target through SOCKS5 proxy
122
122
  def socks5_connect(host, port)
123
123
  if host.bytesize > MAX_HOSTNAME_LENGTH
124
- raise Excon::Error::Socket.new(Exception.new("SOCKS5: hostname exceeds maximum length of #{MAX_HOSTNAME_LENGTH} bytes"))
124
+ raise Excon::Error::SOCKS5Error, "SOCKS5: hostname exceeds maximum length of #{MAX_HOSTNAME_LENGTH} bytes"
125
125
  end
126
126
 
127
127
  # Build CONNECT request with domain name (let proxy resolve DNS)
@@ -135,12 +135,12 @@ module Excon
135
135
  version, reply, _, atyp = response.unpack('CCCC')
136
136
 
137
137
  if version != SOCKS5_VERSION
138
- raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned invalid version: #{version}"))
138
+ raise Excon::Error::SOCKS5Error, "SOCKS5 proxy returned invalid version: #{version}"
139
139
  end
140
140
 
141
141
  unless reply == SOCKS5_SUCCESS
142
142
  error_msg = SOCKS5_ERRORS[reply] || "Unknown error (#{reply})"
143
- raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy connect failed: #{error_msg}"))
143
+ raise Excon::Error::SOCKS5Error, "SOCKS5 proxy connect failed: #{error_msg}"
144
144
  end
145
145
 
146
146
  # Read and discard bound address (not needed for CONNECT)
@@ -157,7 +157,7 @@ module Excon
157
157
  when SOCKS5_ATYP_IPV6
158
158
  socks5_read_exactly(16 + 2) # 16 bytes IP + 2 bytes port
159
159
  else
160
- raise Excon::Error::Socket.new(Exception.new("SOCKS5 proxy returned unknown address type: #{atyp}"))
160
+ raise Excon::Error::SOCKS5Error, "SOCKS5 proxy returned unknown address type: #{atyp}"
161
161
  end
162
162
  end
163
163
 
@@ -183,7 +183,7 @@ module Excon
183
183
  when :wait_readable
184
184
  IO.select([@socket], nil, nil, deadline ? [deadline - Time.now, 0].max : nil)
185
185
  when nil, ''
186
- raise Excon::Error::Socket.new(Exception.new('SOCKS5 proxy connection closed unexpectedly'))
186
+ raise Excon::Error::SOCKS5Error, 'SOCKS5 proxy connection closed unexpectedly'
187
187
  else
188
188
  data << chunk
189
189
  end
@@ -86,11 +86,8 @@ module Excon
86
86
 
87
87
  if client_cert_data && client_key_data
88
88
  ssl_context.cert = OpenSSL::X509::Certificate.new client_cert_data
89
- if OpenSSL::PKey.respond_to? :read
90
- ssl_context.key = OpenSSL::PKey.read(client_key_data, client_key_pass)
91
- else
92
- ssl_context.key = OpenSSL::PKey::RSA.new(client_key_data, client_key_pass)
93
- end
89
+ ssl_context.key = OpenSSL::PKey.read(client_key_data, client_key_pass)
90
+ # jruby-openssl 0.16.x (bundled with JRuby 10.0) lacks Certificate.load
94
91
  if client_chain_data && OpenSSL::X509::Certificate.respond_to?(:load)
95
92
  ssl_context.extra_chain_cert = OpenSSL::X509::Certificate.load(client_chain_data)
96
93
  elsif client_chain_data
@@ -101,11 +98,7 @@ module Excon
101
98
  end
102
99
  elsif @data.key?(:certificate) && @data.key?(:private_key)
103
100
  ssl_context.cert = OpenSSL::X509::Certificate.new(@data[:certificate])
104
- if OpenSSL::PKey.respond_to? :read
105
- ssl_context.key = OpenSSL::PKey.read(@data[:private_key], client_key_pass)
106
- else
107
- ssl_context.key = OpenSSL::PKey::RSA.new(@data[:private_key], client_key_pass)
108
- end
101
+ ssl_context.key = OpenSSL::PKey.read(@data[:private_key], client_key_pass)
109
102
  end
110
103
 
111
104
  if @data[:proxy]
@@ -129,10 +122,15 @@ module Excon
129
122
  # write out the proxy setup request
130
123
  @socket.write(request)
131
124
 
132
- # eat the proxy's connection response
133
- response = Excon::Response.parse(self, :expects => 200, :method => 'CONNECT')
134
- if response[:response][:status] != 200
135
- raise(Excon::Errors::ProxyConnectionError.new("proxy connection could not be established", request, response))
125
+ # eat the proxy's connection response; any 2xx means the tunnel is
126
+ # open (RFC 9110, section 9.3.6)
127
+ response = Excon::Response.parse(self, method: 'CONNECT')
128
+ unless (200..299).cover?(response[:response][:status])
129
+ # the error keeps the request, which may be logged, so leave out
130
+ # the proxy credentials (which :ssl_proxy_headers may also carry)
131
+ redacted_request = request.gsub(/^(Proxy-Authorization:)[^\r\n]*/i, "\\1 #{Excon::REDACTED}")
132
+ raise(Excon::Errors::ProxyConnectionError.new('proxy connection could not be established',
133
+ redacted_request, response))
136
134
  end
137
135
  end
138
136
 
data/lib/excon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Excon
4
- VERSION = "1.10.0"
4
+ VERSION = "1.11.0"
5
5
  end
data/lib/excon.rb CHANGED
@@ -48,6 +48,9 @@ require 'excon/unix_socket'
48
48
 
49
49
  # Define defaults first so they will be available to other files
50
50
  module Excon
51
+ # guards the stub call counts, which threads share when stubs are :global
52
+ STUB_CALL_COUNTS_MUTEX = Mutex.new
53
+
51
54
  class << self
52
55
  # @return [Hash] defaults for Excon connections
53
56
  def defaults
@@ -178,19 +181,21 @@ module Excon
178
181
  else
179
182
  raise(ArgumentError, 'stub requires either response_params OR a block')
180
183
  end
184
+ prune_stub_call_counts
181
185
  stubs.unshift(stub)
182
186
  stub
183
187
  end
184
188
 
185
189
  # get a stub matching params or nil
186
190
  # @param request_params [Hash<Symbol, >] request params to match against, omitted params match all
187
- # @return [Hash<Symbol, >] response params to return from matched request or block to call with params
191
+ # @return [Array] the matching stub from Excon.stubs, a [request_params, response_params] pair
188
192
  def stub_for(request_params={})
189
193
  if (method = request_params.delete(:method))
190
194
  request_params[:method] = method.to_s.downcase.to_sym
191
195
  end
192
196
  request_params[:query] = Utils.query_string(request_params)[1...] if request_params.key?(:query)
193
- Excon.stubs.each do |stub, response_params|
197
+ Excon.stubs.each do |entry|
198
+ stub, = entry
194
199
  captures = { headers: {} }
195
200
  headers_match = !stub.key?(:headers) || stub[:headers].keys.all? do |key|
196
201
  case value = stub[:headers][key]
@@ -226,7 +231,7 @@ module Excon
226
231
  end
227
232
  if headers_match && non_headers_match
228
233
  request_params[:captures] = captures
229
- return [stub, response_params]
234
+ return entry
230
235
  end
231
236
  end
232
237
  nil
@@ -248,7 +253,44 @@ module Excon
248
253
  def unstub(request_params = {})
249
254
  return unless (stub = stub_for(request_params))
250
255
 
251
- Excon.stubs.delete_at(Excon.stubs.index(stub))
256
+ STUB_CALL_COUNTS_MUTEX.synchronize { stub_call_counts.delete(stub) }
257
+ Excon.stubs.delete_at(Excon.stubs.index { |entry| entry.equal?(stub) })
258
+ end
259
+
260
+ # number of requests a stub has answered
261
+ # counts are only kept for stubs still in Excon.stubs; don't rely on the
262
+ # count of a stub after removing it
263
+ # @param stub [Array] a stub, as returned by Excon.stub or Excon.stub_for
264
+ # @return [Integer] how many times the stub has answered a request
265
+ def stub_call_count(stub)
266
+ STUB_CALL_COUNTS_MUTEX.synchronize { stub_call_counts.fetch(stub, 0) }
267
+ end
268
+
269
+ # raise if any of the given stubs, or of all stubs, has not answered a request
270
+ # @param stubs [Array<Array>] stubs to check, as returned by Excon.stub; omit to check every stub in Excon.stubs
271
+ # @raise [Excon::Error::StubNotCalled] listing the request params of each stub that was never called
272
+ # @return [true] if every stub checked has been called
273
+ def verify_stubs(*stubs)
274
+ stubs = Excon.stubs.dup if stubs.empty?
275
+ uncalled = stubs.select { |stub| stub_call_count(stub).zero? }
276
+ return true if uncalled.empty?
277
+
278
+ message = StringIO.new
279
+ message.puts("#{uncalled.length} of #{stubs.length} stubs not called")
280
+ uncalled.each do |request_params, _|
281
+ message.puts('{')
282
+ Excon::PrettyPrinter.pp(message, request_params)
283
+ message.puts('}')
284
+ end
285
+ raise(Excon::Error::StubNotCalled.new(message.string))
286
+ end
287
+
288
+ # record that a stub answered a request, used by Excon::Middleware::Mock
289
+ # @param stub [Array] the stub, as returned by Excon.stub_for
290
+ def record_stub_call(stub)
291
+ STUB_CALL_COUNTS_MUTEX.synchronize do
292
+ stub_call_counts[stub] = stub_call_counts.fetch(stub, 0) + 1
293
+ end
252
294
  end
253
295
 
254
296
  # Generic non-persistent HTTP methods
@@ -259,5 +301,30 @@ module Excon
259
301
  end
260
302
  DEF
261
303
  end
304
+
305
+ private
306
+
307
+ # call counts for stubs, keyed by the stub object itself (not by equality,
308
+ # so two identical stubs are counted apart); kept alongside Excon.stubs
309
+ def stub_call_counts
310
+ case Excon.defaults[:stubs]
311
+ when :global
312
+ @stub_call_counts ||= {}.compare_by_identity
313
+ when :local
314
+ Thread.current[:_excon_stub_call_counts] ||= {}.compare_by_identity
315
+ end
316
+ end
317
+
318
+ # drop counts for stubs no longer in Excon.stubs (e.g. after Excon.stubs.clear),
319
+ # so they don't hold on to removed stubs
320
+ def prune_stub_call_counts
321
+ STUB_CALL_COUNTS_MUTEX.synchronize do
322
+ counts = stub_call_counts
323
+ next if counts.empty?
324
+
325
+ live = Excon.stubs.each_with_object({}.compare_by_identity) { |stub, set| set[stub] = true }
326
+ counts.delete_if { |stub, _| !live.key?(stub) }
327
+ end
328
+ end
262
329
  end
263
330
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: excon
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dpiddy (Dan Peterson)