http_mimic 0.3.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.
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'fileutils'
7
+ require 'zlib'
8
+ require 'rubygems/package'
9
+ require 'open3'
10
+
11
+ module HttpMimic
12
+ class Downloader
13
+ DEFAULT_GITHUB_REPO = 'lexiforest/curl-impersonate'
14
+ DEFAULT_VERSION = 'v2.1.1'
15
+
16
+ class DownloadError < HttpMimic::Error; end
17
+ class UnsupportedPlatformError < HttpMimic::Error; end
18
+
19
+ class << self
20
+ def download!(version: nil, install_dir: nil, repo: nil, force: false)
21
+ target_version = normalize_version(version || HttpMimic.configuration.driver_version || DEFAULT_VERSION)
22
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
23
+ target_repo = repo || HttpMimic.configuration.github_repo || DEFAULT_GITHUB_REPO
24
+
25
+ FileUtils.mkdir_p(target_dir)
26
+
27
+ # Check if the target version is already installed and intact
28
+ if !force && installed?(version: target_version, install_dir: target_dir)
29
+ log_info("curl-impersonate #{target_version} is already installed in #{target_dir}")
30
+ return target_dir
31
+ end
32
+
33
+ slug = platform_slug
34
+ asset_name = "curl-impersonate-#{target_version}.#{slug}.tar.gz"
35
+ download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
36
+
37
+ log_info("Downloading curl-impersonate (#{target_version}) [#{asset_name}]...")
38
+
39
+ archive_data = fetch_binary(download_url)
40
+
41
+ log_info("Extracting archive to #{target_dir}...")
42
+ extract_tar_gz(archive_data, target_dir)
43
+
44
+ # Write version marker file
45
+ File.write(version_file_path(target_dir), target_version)
46
+
47
+ log_info("curl-impersonate #{target_version} installation complete!")
48
+ target_dir
49
+ end
50
+
51
+ def installed?(version: nil, install_dir: nil)
52
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
53
+ core_binary = File.join(target_dir, binary_name_for_platform('curl-impersonate'))
54
+ return false unless File.file?(core_binary) && File.executable?(core_binary)
55
+
56
+ if version
57
+ target_version = normalize_version(version)
58
+ v_file = version_file_path(target_dir)
59
+ return false unless File.file?(v_file)
60
+ return File.read(v_file).strip == target_version
61
+ end
62
+
63
+ true
64
+ end
65
+
66
+ def binary_path(name, install_dir: nil)
67
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
68
+ candidate = File.join(target_dir, binary_name_for_platform(name))
69
+ return candidate if File.file?(candidate) && File.executable?(candidate)
70
+
71
+ nil
72
+ end
73
+
74
+ def available_binaries(install_dir: nil)
75
+ target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
76
+ return [] unless Dir.exist?(target_dir)
77
+
78
+ Dir.glob(File.join(target_dir, '*')).select do |file|
79
+ File.file?(file) && File.executable?(file)
80
+ end.map { |f| File.basename(f) }
81
+ end
82
+
83
+ def platform_slug
84
+ os = host_os
85
+ cpu = host_cpu
86
+
87
+ case os
88
+ when :macos
89
+ case cpu
90
+ when :arm64 then 'arm64-macos'
91
+ when :x86_64 then 'x86_64-macos'
92
+ else
93
+ raise UnsupportedPlatformError, "Unsupported macOS CPU architecture: #{cpu}"
94
+ end
95
+ when :linux
96
+ libc = linux_libc
97
+ case cpu
98
+ when :x86_64
99
+ libc == :musl ? 'x86_64-linux-musl' : 'x86_64-linux-gnu'
100
+ when :aarch64, :arm64
101
+ libc == :musl ? 'aarch64-linux-musl' : 'aarch64-linux-gnu'
102
+ when :i386, :i686
103
+ 'i386-linux-gnu'
104
+ when :arm
105
+ 'arm-linux-gnueabihf'
106
+ when :riscv64
107
+ 'riscv64-linux-gnu'
108
+ when :loongarch64
109
+ 'loongarch64-linux-gnu'
110
+ else
111
+ raise UnsupportedPlatformError, "Unsupported Linux CPU architecture: #{cpu}"
112
+ end
113
+ when :windows
114
+ case cpu
115
+ when :x86_64 then 'x86_64-win32'
116
+ when :arm64 then 'arm64-win32'
117
+ when :i386, :i686 then 'i686-win32'
118
+ else
119
+ raise UnsupportedPlatformError, "Unsupported Windows CPU architecture: #{cpu}"
120
+ end
121
+ when :freebsd
122
+ case cpu
123
+ when :x86_64 then 'x86_64-freebsd'
124
+ when :arm64, :aarch64 then 'aarch64-freebsd'
125
+ else
126
+ raise UnsupportedPlatformError, "Unsupported FreeBSD CPU architecture: #{cpu}"
127
+ end
128
+ else
129
+ raise UnsupportedPlatformError, "Unsupported operating system: #{RbConfig::CONFIG['host_os']}"
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def host_os
136
+ os_str = RbConfig::CONFIG['host_os'].downcase
137
+ case os_str
138
+ when /darwin|mac os/
139
+ :macos
140
+ when /linux/
141
+ :linux
142
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
143
+ :windows
144
+ when /freebsd/
145
+ :freebsd
146
+ else
147
+ :unknown
148
+ end
149
+ end
150
+
151
+ def host_cpu
152
+ cpu_str = RbConfig::CONFIG['host_cpu'].downcase
153
+ case cpu_str
154
+ when /arm64|aarch64/
155
+ host_os == :macos || host_os == :windows ? :arm64 : :aarch64
156
+ when /x86_64|amd64|x64/
157
+ :x86_64
158
+ when /i[3-6]86|x86/
159
+ :i386
160
+ when /arm/
161
+ :arm
162
+ when /riscv64/
163
+ :riscv64
164
+ when /loongarch64/
165
+ :loongarch64
166
+ else
167
+ cpu_str.to_sym
168
+ end
169
+ end
170
+
171
+ def linux_libc
172
+ if File.exist?('/etc/alpine-release') || `ldd --version 2>&1` =~ /musl/i
173
+ :musl
174
+ else
175
+ :gnu
176
+ end
177
+ rescue StandardError
178
+ :gnu
179
+ end
180
+
181
+ def binary_name_for_platform(name)
182
+ host_os == :windows ? "#{name}.exe" : name
183
+ end
184
+
185
+ def normalize_version(ver)
186
+ v = ver.to_s.strip
187
+ v.start_with?('v') ? v : "v#{v}"
188
+ end
189
+
190
+ def version_file_path(dir)
191
+ File.join(dir, '.version')
192
+ end
193
+
194
+ def fetch_binary(url, limit = 5)
195
+ raise DownloadError, "Download failed: too many redirects (#{url})" if limit <= 0
196
+
197
+ uri = URI.parse(url)
198
+ http = Net::HTTP.new(uri.host, uri.port)
199
+ http.use_ssl = (uri.scheme == 'https')
200
+ http.read_timeout = 60
201
+ http.open_timeout = 30
202
+
203
+ req = Net::HTTP::Get.new(uri.request_uri)
204
+ req['User-Agent'] = "Ruby-HttpMimic/#{HttpMimic::VERSION} (#{RUBY_PLATFORM})"
205
+
206
+ res = http.request(req)
207
+
208
+ case res
209
+ when Net::HTTPSuccess
210
+ res.body
211
+ when Net::HTTPRedirection
212
+ location = res['location']
213
+ fetch_binary(location, limit - 1)
214
+ else
215
+ raise DownloadError, "Download failed with HTTP status #{res.code}: #{url}"
216
+ end
217
+ rescue StandardError => e
218
+ raise DownloadError, "Failed to download curl-impersonate release: #{e.message}"
219
+ end
220
+
221
+ def extract_tar_gz(data, dest_dir)
222
+ # 1. Try system tar command first
223
+ begin
224
+ tmp_tar = File.join(dest_dir, ".temp_download_#{Process.pid}.tar.gz")
225
+ File.binwrite(tmp_tar, data)
226
+
227
+ _stdout, _stderr, status = Open3.capture3('tar', '-xzf', tmp_tar, '-C', dest_dir)
228
+ FileUtils.rm_f(tmp_tar)
229
+
230
+ if status && status.success?
231
+ chmod_all_binaries(dest_dir)
232
+ return
233
+ end
234
+ rescue StandardError => e
235
+ log_info("System tar command failed (#{e.message}), falling back to Ruby extraction...")
236
+ end
237
+
238
+ # 2. Pure Ruby tar.gz extraction fallback
239
+ gz = Zlib::GzipReader.new(StringIO.new(data))
240
+ tar = Gem::Package::TarReader.new(gz)
241
+
242
+ tar.each do |entry|
243
+ target_path = File.join(dest_dir, entry.full_name)
244
+ if entry.directory?
245
+ FileUtils.mkdir_p(target_path)
246
+ elsif entry.file?
247
+ FileUtils.mkdir_p(File.dirname(target_path))
248
+ File.binwrite(target_path, entry.read)
249
+ File.chmod(0755, target_path)
250
+ end
251
+ end
252
+ tar.close
253
+ gz.close
254
+ chmod_all_binaries(dest_dir)
255
+ end
256
+
257
+ def chmod_all_binaries(dir)
258
+ Dir.glob(File.join(dir, '*')).each do |f|
259
+ File.chmod(0755, f) if File.file?(f)
260
+ end
261
+ end
262
+
263
+ def log_info(msg)
264
+ if HttpMimic.configuration.logger
265
+ HttpMimic.configuration.logger.info("[HttpMimic::Downloader] #{msg}")
266
+ elsif HttpMimic.configuration.debug
267
+ puts "[HttpMimic::Downloader] #{msg}"
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ # Base exception class
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the curl-impersonate binary cannot be found
8
+ class BinaryNotFoundError < Error; end
9
+
10
+ # Raised when the curl command fails or returns a non-zero exit status
11
+ class CommandError < Error
12
+ attr_reader :exit_code, :stderr, :command, :response
13
+
14
+ def initialize(message, exit_code: nil, stderr: nil, command: nil, response: nil)
15
+ super(message)
16
+ @exit_code = exit_code
17
+ @stderr = stderr
18
+ @command = command
19
+ @response = response
20
+ end
21
+ end
22
+
23
+ # Connection timeout (Curl exit code 28)
24
+ class TimeoutError < CommandError; end
25
+
26
+ # Connection failure or DNS resolution error (Curl exit code 6, 7, 52, etc.)
27
+ class ConnectionError < CommandError; end
28
+
29
+ # SSL/TLS handshake or certificate error (Curl exit code 35, 51, 60, etc.)
30
+ class SSLError < CommandError; end
31
+
32
+ # Raised when response parsing fails
33
+ class ResponseParseError < Error; end
34
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ # Case-insensitive HTTP Headers wrapper that preserves original casing
5
+ class Headers
6
+ include Enumerable
7
+
8
+ def initialize(headers_hash = {})
9
+ @headers = {}
10
+ @names = {}
11
+ @multi_values = {}
12
+
13
+ headers_hash.each do |key, val|
14
+ self[key] = val
15
+ end
16
+ end
17
+
18
+ def [](key)
19
+ return nil if key.nil?
20
+ canonical = @names[normalize_key(key)]
21
+ return nil unless canonical
22
+
23
+ norm_key = normalize_key(key)
24
+ if norm_key == 'set-cookie' && @multi_values.key?(norm_key)
25
+ # For Set-Cookie, return an Array if multiple values exist
26
+ @multi_values[norm_key].size > 1 ? @multi_values[norm_key] : @multi_values[norm_key].first
27
+ else
28
+ @headers[canonical]
29
+ end
30
+ end
31
+
32
+ def []=(key, value)
33
+ return if key.nil?
34
+ norm = normalize_key(key)
35
+ if existing_key = @names[norm]
36
+ @headers.delete(existing_key)
37
+ end
38
+ orig_key = key.to_s
39
+ @names[norm] = orig_key
40
+ @headers[orig_key] = value
41
+
42
+ @multi_values[norm] = value.is_a?(Array) ? value.dup : [value]
43
+ end
44
+
45
+ # Add header (supports duplicate headers with the same name, e.g., multiple Set-Cookie entries)
46
+ def add(key, value)
47
+ return if key.nil?
48
+ norm = normalize_key(key)
49
+ orig_key = @names[norm] || key.to_s
50
+ @names[norm] = orig_key
51
+
52
+ @multi_values[norm] ||= []
53
+ @multi_values[norm] << value
54
+
55
+ @headers[orig_key] = value
56
+ end
57
+
58
+ def get_all(key)
59
+ return [] if key.nil?
60
+ @multi_values[normalize_key(key)] || []
61
+ end
62
+
63
+ def key?(key)
64
+ return false if key.nil?
65
+ @names.key?(normalize_key(key))
66
+ end
67
+ alias has_key? key?
68
+ alias include? key?
69
+
70
+ def each(&block)
71
+ @headers.each(&block)
72
+ end
73
+
74
+ def keys
75
+ @headers.keys
76
+ end
77
+
78
+ def values
79
+ @headers.values
80
+ end
81
+
82
+ def to_h
83
+ @headers.dup
84
+ end
85
+ alias to_hash to_h
86
+
87
+ def inspect
88
+ "#<#{self.class.name} #{@headers.inspect}>"
89
+ end
90
+
91
+ def to_s
92
+ @headers.to_s
93
+ end
94
+
95
+ private
96
+
97
+ def normalize_key(key)
98
+ key.to_s.downcase.tr('_', '-')
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ module ModuleMethods
5
+ def base_uri(uri = nil)
6
+ return default_options[:base_uri] if uri.nil?
7
+ default_options[:base_uri] = uri
8
+ end
9
+
10
+ def headers(h = nil)
11
+ return default_options[:headers] if h.nil?
12
+ default_options[:headers] ||= {}
13
+ default_options[:headers].merge!(h)
14
+ end
15
+
16
+ def default_params(params = nil)
17
+ return default_options[:query] if params.nil?
18
+ default_options[:query] ||= {}
19
+ default_options[:query].merge!(params)
20
+ end
21
+ alias default_query default_params
22
+
23
+ def default_timeout(seconds = nil)
24
+ return default_options[:timeout] if seconds.nil?
25
+ default_options[:timeout] = seconds
26
+ end
27
+
28
+ def impersonate(target = nil)
29
+ return default_options[:impersonate] if target.nil?
30
+ default_options[:impersonate] = target
31
+ end
32
+
33
+ def proxy(proxy_str = nil)
34
+ return default_options[:proxy] if proxy_str.nil?
35
+ default_options[:proxy] = proxy_str
36
+ end
37
+
38
+ def cookies(c = nil)
39
+ return default_options[:cookies] if c.nil?
40
+ default_options[:cookies] ||= {}
41
+ default_options[:cookies].merge!(c)
42
+ end
43
+
44
+ def default_options
45
+ @default_options ||= {}
46
+ end
47
+
48
+ def get(url, options = {})
49
+ request(:get, url, options)
50
+ end
51
+
52
+ def post(url, options = {})
53
+ request(:post, url, options)
54
+ end
55
+
56
+ def put(url, options = {})
57
+ request(:put, url, options)
58
+ end
59
+
60
+ def patch(url, options = {})
61
+ request(:patch, url, options)
62
+ end
63
+
64
+ def delete(url, options = {})
65
+ request(:delete, url, options)
66
+ end
67
+
68
+ def head(url, options = {})
69
+ request(:head, url, options)
70
+ end
71
+
72
+ def options(url, options = {})
73
+ request(:options, url, options)
74
+ end
75
+
76
+ def request(method, url, options = {})
77
+ merged = default_options.merge(options)
78
+
79
+ # Deep merge headers
80
+ if default_options[:headers] || options[:headers]
81
+ base_h = default_options[:headers] || {}
82
+ opt_h = options[:headers] || {}
83
+ merged[:headers] = base_h.merge(opt_h)
84
+ end
85
+
86
+ # Deep merge query
87
+ base_q = default_options[:query] || {}
88
+ opt_q = options[:query] || options[:params] || {}
89
+ if !base_q.empty? || !opt_q.empty?
90
+ merged[:query] = base_q.merge(opt_q)
91
+ end
92
+
93
+ # Deep merge cookies
94
+ if default_options[:cookies].is_a?(Hash) && options[:cookies].is_a?(Hash)
95
+ merged[:cookies] = default_options[:cookies].merge(options[:cookies])
96
+ end
97
+
98
+ Request.new(method, url, merged).perform
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module HttpMimic
6
+ class Request
7
+ attr_reader :method, :url, :options, :config
8
+
9
+ def initialize(method, url, options = {}, config = nil)
10
+ @method = method
11
+ @url = url
12
+ @options = options.dup
13
+ @config = config || HttpMimic.configuration
14
+ end
15
+
16
+ def perform
17
+ builder = CommandBuilder.new(method, url, options, config)
18
+ binary, args, stdin_data, final_url = builder.build
19
+
20
+ full_command = [binary] + args
21
+
22
+ log_debug("Executing HttpMimic command: #{full_command.join(' ')}")
23
+ log_debug("Stdin data: #{stdin_data}") if stdin_data
24
+
25
+ stdout, stderr, status = execute_open3(full_command, stdin_data)
26
+
27
+ log_debug("Curl exit status: #{status.exitstatus}")
28
+ log_debug("Curl stderr: #{stderr}") unless stderr.empty?
29
+
30
+ response = ResponseParser.new(
31
+ stdout,
32
+ exit_status: status,
33
+ stderr: stderr,
34
+ command: full_command,
35
+ request_url: final_url
36
+ ).parse
37
+
38
+ handle_errors(status, stderr, full_command, response)
39
+
40
+ response
41
+ end
42
+
43
+ private
44
+
45
+ def execute_open3(command_array, stdin_data)
46
+ if stdin_data
47
+ Open3.capture3(*command_array, stdin_data: stdin_data)
48
+ else
49
+ Open3.capture3(*command_array)
50
+ end
51
+ rescue Errno::ENOENT => e
52
+ raise BinaryNotFoundError, "Executable not found #{command_array.first}: #{e.message}"
53
+ end
54
+
55
+ def handle_errors(status, stderr, command, response)
56
+ raise_error = options.fetch(:raise_on_error, config.raise_on_error)
57
+ exit_code = status.exitstatus
58
+
59
+ if exit_code != 0
60
+ message = "curl execution error (exit code #{exit_code}): #{stderr.strip}"
61
+ error_class = case exit_code
62
+ when 28
63
+ TimeoutError
64
+ when 6, 7, 52
65
+ ConnectionError
66
+ when 35, 51, 60
67
+ SSLError
68
+ else
69
+ CommandError
70
+ end
71
+
72
+ if raise_error
73
+ raise error_class.new(message, exit_code: exit_code, stderr: stderr, command: command, response: response)
74
+ end
75
+ elsif raise_error && response.error?
76
+ raise CommandError.new("HTTP request failed (status #{response.code})", exit_code: exit_code, stderr: stderr, command: command, response: response)
77
+ end
78
+ end
79
+
80
+ def log_debug(message)
81
+ if config.logger
82
+ config.logger.debug(message)
83
+ elsif config.debug || options[:debug]
84
+ puts "[HttpMimic DEBUG] #{message}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpMimic
4
+ class Response
5
+ attr_reader :code,
6
+ :http_version,
7
+ :status_message,
8
+ :headers,
9
+ :cookies,
10
+ :body,
11
+ :parsed_response,
12
+ :raw_headers,
13
+ :history,
14
+ :request_url,
15
+ :stderr,
16
+ :exit_code,
17
+ :command
18
+
19
+ alias status code
20
+
21
+ def initialize(attributes = {})
22
+ @code = attributes[:code] || 0
23
+ @http_version = attributes[:http_version]
24
+ @status_message = attributes[:status_message]
25
+ @headers = attributes[:headers] || Headers.new
26
+ @cookies = attributes[:cookies] || Cookies.new
27
+ @body = attributes[:body] || ''
28
+ @parsed_response = attributes[:parsed_response]
29
+ @raw_headers = attributes[:raw_headers] || ''
30
+ @history = attributes[:history] || []
31
+ @request_url = attributes[:request_url]
32
+ @stderr = attributes[:stderr] || ''
33
+ @exit_code = attributes[:exit_code] || 0
34
+ @command = attributes[:command]
35
+ end
36
+
37
+ def success?
38
+ code >= 200 && code < 300
39
+ end
40
+ alias ok? success?
41
+
42
+ def redirect?
43
+ code >= 300 && code < 400
44
+ end
45
+
46
+ def client_error?
47
+ code >= 400 && code < 500
48
+ end
49
+
50
+ def server_error?
51
+ code >= 500 && code < 600
52
+ end
53
+
54
+ def error?
55
+ client_error? || server_error?
56
+ end
57
+
58
+ def [](key)
59
+ if parsed_response.is_a?(Hash) || parsed_response.is_a?(Array)
60
+ parsed_response[key]
61
+ else
62
+ nil
63
+ end
64
+ end
65
+
66
+ def to_s
67
+ body.to_s
68
+ end
69
+ alias to_str to_s
70
+
71
+ def blank?
72
+ body.nil? || body.strip.empty?
73
+ end
74
+
75
+ def present?
76
+ !blank?
77
+ end
78
+
79
+ def inspect
80
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @code=#{code} @status_message=#{status_message.inspect} @headers=#{headers.to_h.inspect} @parsed_response=#{parsed_response.inspect}>"
81
+ end
82
+
83
+ def method_missing(method_name, *args, &block)
84
+ if parsed_response.respond_to?(method_name)
85
+ parsed_response.public_send(method_name, *args, &block)
86
+ elsif body.respond_to?(method_name)
87
+ body.public_send(method_name, *args, &block)
88
+ else
89
+ super
90
+ end
91
+ end
92
+
93
+ def respond_to_missing?(method_name, include_private = false)
94
+ parsed_response.respond_to?(method_name, include_private) ||
95
+ body.respond_to?(method_name, include_private) ||
96
+ super
97
+ end
98
+ end
99
+ end