curl_impersonate 0.1.1-x86_64-linux-gnu

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6febfd1e0a8669f78cd31a9a0b198a331c0afc55c9cab8a26251e26420e6da07
4
+ data.tar.gz: 4b56a58989196518da27a9b621e4cd80fd366063ac693c2aa47e5f0167391c36
5
+ SHA512:
6
+ metadata.gz: dc5220e8a427e79601f8cf4c6fcc19c60bec03e37e8bbaebb837a0b5314941dd9e177dc5fe1f9e3d47b2c91babafa8aaa81e471bed6d670acecb727fa2fa7326
7
+ data.tar.gz: 3469d1dc71a6ec83d01e7a4130af485a1dd1aa9a39fca1f7204ab6e0782c9c907a65a6568bddc2fa13795eda0a31260a1fe6073ed2d6c2076774ece8a54eac09
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TeamMilestone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # curl_impersonate
2
+
3
+ Ruby bindings for [libcurl-impersonate](https://github.com/lexiforest/curl-impersonate) — generate real-browser TLS / JA3 / JA4 fingerprints from Ruby.
4
+
5
+ Unlike pure-Ruby HTTP libraries (`net/http`, `httpx`, `faraday`, …) whose TLS handshakes are unmistakably "not a browser," this gem links the same BoringSSL-backed `libcurl-impersonate` build that the `curl-impersonate` project ships, so the TLS ClientHello, HTTP/2 SETTINGS frame, and header ordering are byte-identical to Chrome, Firefox, or Safari.
6
+
7
+ This is a Ruby port of [`go-curl-impersonate`](https://github.com/TeamMilestone/go-curl-impersonate). The public API matches it 1:1 within Ruby idioms.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "curl_impersonate"
14
+ ```
15
+
16
+ On platforms with a precompiled gem (`arm64-darwin`, `x86_64-linux-gnu`), `gem install` is a single step with no external dependencies — the BoringSSL build is statically linked into the gem's native extension.
17
+
18
+ On other platforms you will need to build from source; see [Building from source](#building-from-source).
19
+
20
+ ## Quick start
21
+
22
+ ```ruby
23
+ require "curl_impersonate"
24
+
25
+ resp = CurlImpersonate.do_request(
26
+ url: "https://example.com",
27
+ impersonate: "chrome131",
28
+ headers: { "Accept-Language" => "en-US" },
29
+ timeout_sec: 15,
30
+ )
31
+
32
+ resp.status_code # => 200
33
+ resp.success? # => true
34
+ resp.body # => "<!doctype html>..."
35
+ resp.headers # => "HTTP/2 200\r\ncontent-type: text/html\r\n..."
36
+
37
+ CurlImpersonate.extract_cookies(resp.headers) # => { "name" => "value", ... }
38
+ ```
39
+
40
+ ## API
41
+
42
+ ### `CurlImpersonate.do_request(...)` → `Response`
43
+
44
+ | keyword | type | default | notes |
45
+ |---------|------|---------|-------|
46
+ | `url:` | `String` | — | Required. |
47
+ | `impersonate:` | `String` | `"chrome131"` | Any target supported by libcurl-impersonate. Examples: `chrome131`, `firefox133`, `safari180`. See the [upstream targets](https://github.com/lexiforest/curl-impersonate#supported-browsers). |
48
+ | `headers:` | `Hash<String, String>` | `{}` | Custom headers merged on top of the browser default set. |
49
+ | `post_data:` | `String` | `""` | Non-empty switches the request to POST and is sent as the body verbatim. |
50
+ | `follow_redirects:` | `Boolean` | `true` | Maps to `CURLOPT_FOLLOWLOCATION`. |
51
+ | `timeout_sec:` | `Integer` | `15` | Total request timeout. |
52
+ | `proxy:` | `String` | `""` | `"scheme://user:pass@host:port"`. Credentials are split out and passed via `CURLOPT_PROXYUSERPWD`. |
53
+
54
+ Returns a `CurlImpersonate::Response` Struct with `status_code` (Integer), `body` (String), and `headers` (String — raw `\r\n`-separated lines). On any libcurl error (resolution, timeout, TLS handshake, …) raises `CurlImpersonate::Error`.
55
+
56
+ `curl_easy_perform` runs without the GVL, so other Ruby threads continue executing during the network wait.
57
+
58
+ ### `CurlImpersonate.extract_cookies(headers_str)` → `Hash`
59
+
60
+ Parses `Set-Cookie:` lines from a raw header string and returns a `Hash<String, String>` of cookie names → values. Attributes (`Path`, `Domain`, `HttpOnly`, `Expires`, `Secure`, …) are discarded. Later cookies with the same name overwrite earlier ones.
61
+
62
+ ## Building from source
63
+
64
+ You'll need:
65
+
66
+ - A C compiler (`clang` / `gcc`) and `make`
67
+ - `libcurl-impersonate` 1.5.x installed via one of:
68
+ - **macOS**: `brew install teammilestone/tap/libcurl-impersonate`
69
+ - **Linux**: download the matching tarball from [lexiforest/curl-impersonate releases](https://github.com/lexiforest/curl-impersonate/releases) and copy `libcurl-impersonate.a` plus headers into a system path discoverable by `pkg-config`
70
+ - Or set `CURL_IMPERSONATE_DIR=/path/to/install` (containing `lib/libcurl-impersonate.a` and `include/`) before `gem install`
71
+
72
+ ```bash
73
+ gem install curl_impersonate --platform=ruby # forces source compilation
74
+ ```
75
+
76
+ ## Caveats
77
+
78
+ - **SSL certificate verification is disabled** (`CURLOPT_SSL_VERIFYPEER = 0`, `CURLOPT_SSL_VERIFYHOST = 0`). This matches the Go reference implementation. The premise of TLS impersonation is that you are mimicking a browser TLS stack regardless of trust chain validation; if you need real verification, this gem is the wrong tool.
79
+ - **JA3 hash varies between requests.** Chrome injects random GREASE values into the cipher and extension lists on every TLS handshake, so the JA3 string and its hash change each time. This is the correct Chrome behavior — match on **JA4** (which sorts before hashing) if you need a stable fingerprint to assert against.
80
+ - **Status of this gem**: under active development; API is not stable until 1.0.
81
+
82
+ ## License
83
+
84
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,240 @@
1
+ #include <ruby.h>
2
+ #include <ruby/thread.h>
3
+ #include <curl/curl.h>
4
+ #include <stdlib.h>
5
+ #include <string.h>
6
+
7
+ #ifdef HAVE_CURL_IMPERSONATE_CURL_IMPERSONATE_H
8
+ # include <curl-impersonate/curl_impersonate.h>
9
+ #else
10
+ extern CURLcode curl_easy_impersonate(CURL *curl, const char *target, int default_headers);
11
+ #endif
12
+
13
+ static VALUE mCurlImpersonate;
14
+ static VALUE cResponse;
15
+ static VALUE eError;
16
+
17
+ struct buffer {
18
+ char *data;
19
+ size_t len;
20
+ size_t cap;
21
+ };
22
+
23
+ static void buffer_init(struct buffer *b) { b->data = NULL; b->len = 0; b->cap = 0; }
24
+ static void buffer_free(struct buffer *b) { free(b->data); b->data = NULL; b->len = 0; b->cap = 0; }
25
+
26
+ static int buffer_append(struct buffer *b, const char *src, size_t n) {
27
+ size_t needed = b->len + n;
28
+ if (needed > b->cap) {
29
+ size_t new_cap = b->cap == 0 ? 4096 : b->cap;
30
+ while (new_cap < needed) new_cap *= 2;
31
+ char *p = realloc(b->data, new_cap);
32
+ if (!p) return -1;
33
+ b->data = p;
34
+ b->cap = new_cap;
35
+ }
36
+ memcpy(b->data + b->len, src, n);
37
+ b->len += n;
38
+ return 0;
39
+ }
40
+
41
+ static size_t write_body_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
42
+ size_t total = size * nmemb;
43
+ return buffer_append((struct buffer *)userdata, ptr, total) == 0 ? total : 0;
44
+ }
45
+
46
+ static size_t write_header_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
47
+ size_t total = size * nmemb;
48
+ return buffer_append((struct buffer *)userdata, ptr, total) == 0 ? total : 0;
49
+ }
50
+
51
+ struct perform_args {
52
+ CURL *handle;
53
+ CURLcode result;
54
+ };
55
+
56
+ static void *perform_without_gvl(void *data) {
57
+ struct perform_args *args = (struct perform_args *)data;
58
+ args->result = curl_easy_perform(args->handle);
59
+ return NULL;
60
+ }
61
+
62
+ /* Iterator state passed to rb_hash_foreach to build a curl_slist of "Key: Value"
63
+ * strings from a Ruby Hash. */
64
+ struct slist_build {
65
+ struct curl_slist *list;
66
+ int error; /* non-zero on first conversion failure */
67
+ };
68
+
69
+ static int build_header_slist(VALUE key, VALUE val, VALUE arg) {
70
+ struct slist_build *s = (struct slist_build *)arg;
71
+ if (s->error) return ST_STOP;
72
+
73
+ VALUE k = rb_check_string_type(key);
74
+ VALUE v = rb_check_string_type(val);
75
+ if (NIL_P(k) || NIL_P(v)) { s->error = 1; return ST_STOP; }
76
+
77
+ /* "<key>: <value>\0" — RFC 7230 header line */
78
+ long klen = RSTRING_LEN(k);
79
+ long vlen = RSTRING_LEN(v);
80
+ char *line = malloc((size_t)klen + 2 + (size_t)vlen + 1);
81
+ if (!line) { s->error = 1; return ST_STOP; }
82
+
83
+ memcpy(line, RSTRING_PTR(k), klen);
84
+ line[klen] = ':';
85
+ line[klen + 1] = ' ';
86
+ memcpy(line + klen + 2, RSTRING_PTR(v), vlen);
87
+ line[klen + 2 + vlen] = '\0';
88
+
89
+ struct curl_slist *next = curl_slist_append(s->list, line);
90
+ free(line);
91
+ if (!next) { s->error = 1; return ST_STOP; }
92
+ s->list = next;
93
+ return ST_CONTINUE;
94
+ }
95
+
96
+ static VALUE rb_cci_native_version(VALUE self) {
97
+ (void)self;
98
+ return rb_str_new_cstr(curl_version());
99
+ }
100
+
101
+ /* Signature (stage 7):
102
+ * _do_request_native(url, impersonate, headers, post_data,
103
+ * follow_redirects, timeout_sec,
104
+ * proxy_url, proxy_userpwd) -> Response
105
+ *
106
+ * url : String
107
+ * impersonate : String (e.g. "chrome131")
108
+ * headers : Hash<String, String>
109
+ * post_data : String — empty string means GET, non-empty means POST
110
+ * follow_redirects : true / false
111
+ * timeout_sec : Integer
112
+ * proxy_url : String — empty string disables; otherwise host (no auth)
113
+ * proxy_userpwd : String — "user:pass" or empty
114
+ */
115
+ static VALUE rb_cci_do_request(int argc, VALUE *argv, VALUE self) {
116
+ (void)self;
117
+ if (argc != 8) {
118
+ rb_raise(rb_eArgError, "wrong number of arguments (given %d, expected 8)", argc);
119
+ }
120
+
121
+ VALUE rb_url = argv[0];
122
+ VALUE rb_impersonate = argv[1];
123
+ VALUE rb_headers = argv[2];
124
+ VALUE rb_post_data = argv[3];
125
+ VALUE rb_follow = argv[4];
126
+ VALUE rb_timeout = argv[5];
127
+ VALUE rb_proxy_url = argv[6];
128
+ VALUE rb_proxy_userpwd = argv[7];
129
+
130
+ Check_Type(rb_url, T_STRING);
131
+ Check_Type(rb_impersonate, T_STRING);
132
+ Check_Type(rb_headers, T_HASH);
133
+ Check_Type(rb_post_data, T_STRING);
134
+ Check_Type(rb_proxy_url, T_STRING);
135
+ Check_Type(rb_proxy_userpwd, T_STRING);
136
+
137
+ const char *url = StringValueCStr(rb_url);
138
+ const char *impersonate = StringValueCStr(rb_impersonate);
139
+ long timeout_sec = NUM2LONG(rb_timeout);
140
+ long follow = RTEST(rb_follow) ? 1L : 0L;
141
+
142
+ CURL *handle = curl_easy_init();
143
+ if (!handle) {
144
+ rb_raise(eError, "curl_easy_init failed");
145
+ }
146
+
147
+ CURLcode rc = curl_easy_impersonate(handle, impersonate, 1);
148
+ if (rc != CURLE_OK) {
149
+ curl_easy_cleanup(handle);
150
+ rb_raise(eError, "curl_easy_impersonate(%s) failed: %s",
151
+ impersonate, curl_easy_strerror(rc));
152
+ }
153
+
154
+ struct buffer body, headers_buf;
155
+ buffer_init(&body);
156
+ buffer_init(&headers_buf);
157
+
158
+ curl_easy_setopt(handle, CURLOPT_URL, url);
159
+ curl_easy_setopt(handle, CURLOPT_TIMEOUT, timeout_sec);
160
+ curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, follow);
161
+ curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 0L);
162
+ curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 0L);
163
+ /* Empty string enables all built-in encodings (gzip, br, zstd if available) —
164
+ * essential for matching browser Accept-Encoding fingerprint. */
165
+ curl_easy_setopt(handle, CURLOPT_ACCEPT_ENCODING, "");
166
+ curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, write_body_cb);
167
+ curl_easy_setopt(handle, CURLOPT_WRITEDATA, &body);
168
+ curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, write_header_cb);
169
+ curl_easy_setopt(handle, CURLOPT_HEADERDATA, &headers_buf);
170
+
171
+ /* POST body. We use COPYPOSTFIELDS so libcurl owns the copy and we can
172
+ * release the Ruby String after setopt returns. POSTFIELDSIZE is set
173
+ * explicitly so binary bodies with embedded NULs work. */
174
+ long post_len = RSTRING_LEN(rb_post_data);
175
+ if (post_len > 0) {
176
+ curl_easy_setopt(handle, CURLOPT_POSTFIELDSIZE, post_len);
177
+ curl_easy_setopt(handle, CURLOPT_COPYPOSTFIELDS, RSTRING_PTR(rb_post_data));
178
+ }
179
+
180
+ if (RSTRING_LEN(rb_proxy_url) > 0) {
181
+ curl_easy_setopt(handle, CURLOPT_PROXY, StringValueCStr(rb_proxy_url));
182
+ }
183
+ if (RSTRING_LEN(rb_proxy_userpwd) > 0) {
184
+ curl_easy_setopt(handle, CURLOPT_PROXYUSERPWD, StringValueCStr(rb_proxy_userpwd));
185
+ }
186
+
187
+ /* Custom headers. Build a curl_slist from the Ruby Hash. */
188
+ struct slist_build sb = { .list = NULL, .error = 0 };
189
+ if (RHASH_SIZE(rb_headers) > 0) {
190
+ rb_hash_foreach(rb_headers, build_header_slist, (VALUE)&sb);
191
+ if (sb.error) {
192
+ curl_slist_free_all(sb.list);
193
+ buffer_free(&body);
194
+ buffer_free(&headers_buf);
195
+ curl_easy_cleanup(handle);
196
+ rb_raise(eError, "failed to build header list (non-string key/value or OOM)");
197
+ }
198
+ curl_easy_setopt(handle, CURLOPT_HTTPHEADER, sb.list);
199
+ }
200
+
201
+ struct perform_args args = { .handle = handle, .result = CURLE_OK };
202
+ rb_thread_call_without_gvl(perform_without_gvl, &args, RUBY_UBF_IO, NULL);
203
+
204
+ if (args.result != CURLE_OK) {
205
+ char errbuf[256];
206
+ strncpy(errbuf, curl_easy_strerror(args.result), sizeof(errbuf) - 1);
207
+ errbuf[sizeof(errbuf) - 1] = '\0';
208
+ curl_slist_free_all(sb.list);
209
+ buffer_free(&body);
210
+ buffer_free(&headers_buf);
211
+ curl_easy_cleanup(handle);
212
+ rb_raise(eError, "curl_easy_perform failed: %s", errbuf);
213
+ }
214
+
215
+ long status_code = 0;
216
+ curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status_code);
217
+
218
+ VALUE rb_body = rb_str_new(body.data ? body.data : "", body.len);
219
+ VALUE rb_hdr_str = rb_str_new(headers_buf.data ? headers_buf.data : "", headers_buf.len);
220
+
221
+ curl_slist_free_all(sb.list);
222
+ buffer_free(&body);
223
+ buffer_free(&headers_buf);
224
+ curl_easy_cleanup(handle);
225
+
226
+ return rb_struct_new(cResponse, LONG2NUM(status_code), rb_body, rb_hdr_str);
227
+ }
228
+
229
+ void Init_curl_impersonate(void) {
230
+ curl_global_init(CURL_GLOBAL_DEFAULT);
231
+
232
+ mCurlImpersonate = rb_define_module("CurlImpersonate");
233
+ eError = rb_const_get(mCurlImpersonate, rb_intern("Error"));
234
+ cResponse = rb_const_get(mCurlImpersonate, rb_intern("Response"));
235
+
236
+ rb_define_singleton_method(mCurlImpersonate, "_native_curl_version",
237
+ rb_cci_native_version, 0);
238
+ rb_define_singleton_method(mCurlImpersonate, "_do_request_native",
239
+ rb_cci_do_request, -1);
240
+ }
@@ -0,0 +1,139 @@
1
+ require "rbconfig"
2
+ require "shellwords"
3
+
4
+ # Avoid baking an absolute libruby.dylib install_name into the resulting
5
+ # .bundle on macOS: clear LIBRUBYARG_SHARED *before* mkmf is loaded, so its
6
+ # Makefile template renders LIBS without `-lruby.X.Y`. The dynamic loader will
7
+ # satisfy Ruby symbols from the host process at require time — this is the
8
+ # documented portable pattern for macOS Ruby extensions and is also how
9
+ # nokogiri/sqlite3 ship their precompiled darwin gems.
10
+ if RUBY_PLATFORM =~ /darwin/
11
+ # mkmf uses MAKEFILE_CONFIG (NOT CONFIG) for $(LIBRUBYARG_SHARED) expansion
12
+ # inside the generated Makefile. Clear both for safety.
13
+ RbConfig::CONFIG["LIBRUBYARG_SHARED"] = ""
14
+ RbConfig::CONFIG["LIBRUBYARG"] = ""
15
+ RbConfig::MAKEFILE_CONFIG["LIBRUBYARG_SHARED"] = ""
16
+ RbConfig::MAKEFILE_CONFIG["LIBRUBYARG"] = ""
17
+ end
18
+
19
+ require "mkmf"
20
+
21
+ # Resolve libcurl-impersonate location.
22
+ #
23
+ # Priority:
24
+ # 1. ENV["CURL_IMPERSONATE_DIR"] — explicit override (CI)
25
+ # 2. ext/curl_impersonate/vendor/<arch>/ — bundled in precompiled gem
26
+ # 3. pkg-config --libs --cflags libcurl-impersonate — system install (brew/apt)
27
+ #
28
+ # In all cases we end up statically linking libcurl-impersonate.a so that the
29
+ # resulting .bundle / .so has BoringSSL baked in and the end user does not need
30
+ # the library installed at runtime.
31
+
32
+ def configure_from_dir(dir)
33
+ raise "CURL_IMPERSONATE_DIR=#{dir} does not exist" unless File.directory?(dir)
34
+
35
+ lib_dir = File.join(dir, "lib")
36
+ include_dir = File.join(dir, "include")
37
+
38
+ $LIBPATH.unshift(lib_dir) if File.directory?(lib_dir)
39
+ $CFLAGS << " -I#{include_dir.shellescape}" if File.directory?(include_dir)
40
+ $CFLAGS << " -I#{File.join(include_dir, "curl-impersonate").shellescape}" if File.directory?(File.join(include_dir, "curl-impersonate"))
41
+
42
+ static_archive = File.join(lib_dir, "libcurl-impersonate.a")
43
+ unless File.exist?(static_archive)
44
+ raise "libcurl-impersonate.a not found under #{lib_dir}"
45
+ end
46
+
47
+ # Link the static archive directly so its symbols (including curl_easy_impersonate)
48
+ # end up in our .bundle / .so.
49
+ $LDFLAGS << " #{static_archive.shellescape}"
50
+
51
+ # libcurl-impersonate.a bundles BoringSSL, nghttp{2,3}, ngtcp2, zstd, and
52
+ # brotli statically — but its remaining external dependencies have to be
53
+ # linked separately. The list differs by target platform; we use the
54
+ # vendor directory name as a hint when one is available, otherwise fall
55
+ # back to RUBY_PLATFORM.
56
+ target = File.basename(dir)
57
+ target = RUBY_PLATFORM if target.empty? || target == "vendor"
58
+
59
+ case target
60
+ when /darwin/
61
+ # Source: pkg-config --libs libcurl-impersonate on brew installation.
62
+ $LDFLAGS << " -framework CoreFoundation -framework SystemConfiguration"
63
+ $LDFLAGS << " -framework Security -framework LDAP"
64
+ # ruby/setup-ruby on macos-14 builds a Ruby whose libruby has an absolute
65
+ # install_name (e.g. /Users/runner/hostedtoolcache/Ruby/3.3.11/arm64/lib/
66
+ # libruby.3.3.dylib). Any extension we statically-link against that Ruby
67
+ # inherits the absolute reference, breaking the precompiled gem on every
68
+ # other machine. Tell the linker to leave Ruby symbols unresolved and let
69
+ # the dynamic loader fill them in at require time — this is the standard
70
+ # macOS pattern for portable Ruby extensions.
71
+ $DLDFLAGS << " -Wl,-undefined,dynamic_lookup"
72
+ $libs = "#{$libs} -lresolv -liconv -lz -lc++"
73
+ when /linux/
74
+ # The lexiforest release tarball is a fully-static archive — zstd, brotli,
75
+ # idn2, nghttp{2,3}, ngtcp2, BoringSSL, and a copy of curl itself are all
76
+ # statically linked into libcurl-impersonate.a. The only external symbols
77
+ # are the system C/C++ runtime, zlib (often satisfied by libz.so.1 which
78
+ # ships on every distro), and a handful of pthread/dl/m intrinsics.
79
+ $libs = "#{$libs} -lz -lpthread -ldl -lm -lstdc++"
80
+ end
81
+ end
82
+
83
+ def configure_from_pkg_config
84
+ unless pkg_config("libcurl-impersonate")
85
+ raise "pkg-config could not locate libcurl-impersonate"
86
+ end
87
+
88
+ # pkg_config sets $LIBS to "-lcurl-impersonate -lz ...". We want the static .a
89
+ # baked into the .bundle, so prepend the absolute path to the archive and strip
90
+ # the dynamic -lcurl-impersonate flag.
91
+ libdir = `pkg-config --variable=libdir libcurl-impersonate`.strip
92
+ static_archive = File.join(libdir, "libcurl-impersonate.a")
93
+ unless File.exist?(static_archive)
94
+ raise "libcurl-impersonate.a not found at #{static_archive}"
95
+ end
96
+
97
+ $libs = $libs.to_s.gsub(/-lcurl-impersonate\b/, "").strip
98
+ $LDFLAGS << " #{static_archive.shellescape}"
99
+ end
100
+
101
+ # Look for vendored libcurl-impersonate under any of these directory names.
102
+ # RUBY_PLATFORM on Darwin includes the OS major version ("arm64-darwin25") but
103
+ # the rake-compiler / rubygems platform triple does not ("arm64-darwin"), so
104
+ # we accept either. The order matters — most-specific wins.
105
+ vendor_candidates = [
106
+ RUBY_PLATFORM, # arm64-darwin25, x86_64-linux, ...
107
+ RUBY_PLATFORM.sub(/\d+\z/, ""), # arm64-darwin
108
+ RUBY_PLATFORM.sub(/-gnu\z/, ""), # x86_64-linux (from x86_64-linux-gnu)
109
+ ].uniq.map { |t| File.join(__dir__, "vendor", t) }
110
+
111
+ vendor_arch_dir = vendor_candidates.find { |d| File.directory?(d) }
112
+
113
+ # Always make our vendored upstream curl 8.15.0 headers visible. brew installs
114
+ # only curl_impersonate.h on macOS (no full curl/ tree) and many Linux runners
115
+ # do not ship libcurl-dev. The .a we link against is the matching curl 8.15.0
116
+ # build, so using these headers is the correct pairing regardless of what is
117
+ # (or is not) in /usr/include/curl/.
118
+ bundled_headers = File.join(__dir__, "include")
119
+ $CFLAGS << " -I#{bundled_headers.shellescape}" if File.directory?(bundled_headers)
120
+
121
+ if (override = ENV["CURL_IMPERSONATE_DIR"]) && !override.empty?
122
+ warn "[curl_impersonate] using CURL_IMPERSONATE_DIR=#{override}"
123
+ configure_from_dir(override)
124
+ elsif vendor_arch_dir
125
+ warn "[curl_impersonate] using vendored libcurl-impersonate at #{vendor_arch_dir}"
126
+ configure_from_dir(vendor_arch_dir)
127
+ else
128
+ warn "[curl_impersonate] using pkg-config libcurl-impersonate"
129
+ configure_from_pkg_config
130
+ end
131
+
132
+ # Sanity check: ensure we can find the impersonate header. brew installs it
133
+ # under curl-impersonate/curl_impersonate.h; some custom layouts may place it
134
+ # elsewhere. If missing, fall back to declaring the prototype inline (handled
135
+ # in curl_impersonate.c).
136
+ have_header("curl-impersonate/curl_impersonate.h")
137
+ have_header("curl/curl.h") or abort("curl/curl.h not found — install curl development headers")
138
+
139
+ create_makefile("curl_impersonate/curl_impersonate")