wreq 1.2.16 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e05412727cca397064368fd0c6f79c77efced70c13467850aad2cc4941aeeb9f
4
- data.tar.gz: d5c40280a601d1ead2e79f647ce737bb6f442ed750d04eca609803c1d403dce6
3
+ metadata.gz: a684244b1b3a988034a958ba6d5be4bd1c3233a958834eb103cabf8b57c7c7d2
4
+ data.tar.gz: 2246ef7c885c1d4c429b1a0630795b8fe24bd1923dd4205267dd727ff552bb90
5
5
  SHA512:
6
- metadata.gz: fef4a8ba4a3be468852f69a2d54f8ae2a4b33acdba75258836593ce263ff92993070ad1f5c43db07b5f45f5c0b72d7ca801667c2612238f0797d87faa95271b0
7
- data.tar.gz: a7681af7b04813bb2826eb04b47b4ebb9af9343897f85b241cc7b61756c180ac2712530180044b1d1558a827b8da1032d9e3a8a64e0a7193f24fc31f96470d41
6
+ metadata.gz: 4a1e164422249f5cc1df7d1259879e640c4e822ef3e9e2d0d67c11128e7f90c023119ddb6d1610a098b1d2c43657f72cdf0f0780f29f80f81e58be6b2cf66f72
7
+ data.tar.gz: 8298504fd0e17008572c3d0a7ed0ee1ef949bbce9c88a73729fcece75ba87fb54c43ce38fa1ae961fcf297db8e0c2ca58b528155d14aac588ee23499255b860e
data/Cargo.lock CHANGED
@@ -1490,7 +1490,7 @@ dependencies = [
1490
1490
 
1491
1491
  [[package]]
1492
1492
  name = "wreq-ruby"
1493
- version = "1.2.16"
1493
+ version = "1.3.0"
1494
1494
  dependencies = [
1495
1495
  "arc-swap",
1496
1496
  "bytes",
data/Cargo.toml CHANGED
@@ -5,7 +5,7 @@ authors = ["SearchApi <support@searchapi.io>"]
5
5
  homepage = "https://github.com/SearchApi/wreq-ruby"
6
6
  repository = "https://github.com/SearchApi/wreq-ruby"
7
7
  edition = "2024"
8
- version = "1.2.16"
8
+ version = "1.3.0"
9
9
  rust-version = "1.98"
10
10
 
11
11
  [lib]
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
 
18
18
  ### Fixed
19
19
 
20
+ - Match Trust Anchor ID ordering by Chrome version: randomized per native configuration for 152/153, and sorted by raw ID bytes for 154.
21
+
20
22
  - *(emulate)* Fix Firefox 150 and 151 cipher suite lists.
21
23
 
22
24
  ## [3.0.0-rc.14](https://github.com/0x676e67/wreq-util/compare/v3.0.0-rc.13...v3.0.0-rc.14) - 2026-07-04
@@ -50,13 +50,16 @@ macro_rules! tls_options {
50
50
  .alps_use_new_codepoint(true))
51
51
  };
52
52
  (9, $curves:expr) => {
53
+ tls_options!(9, $curves, shuffled_trust_anchors(crate::rand::fast_random))
54
+ };
55
+ (9, $curves:expr, $trust_anchors:expr) => {
53
56
  tls_options!(@build ChromeTlsConfig::builder()
54
57
  .permute_extensions(true)
55
58
  .enable_ech_grease(true)
56
59
  .pre_shared_key(true)
57
60
  .curves($curves)
58
61
  .sigalgs_list(NEW_SIGALGS_LIST)
59
- .trust_anchors(CHROME_TRUST_ANCHORS)
62
+ .trust_anchors($trust_anchors)
60
63
  .grease_sigalgs_enabled(true)
61
64
  .alps_use_new_codepoint(true))
62
65
  };
@@ -115,6 +118,44 @@ pub const NEW_SIGALGS_LIST: &str = join!(
115
118
  // Encoded IDs and wreq's Chromium root store come from the same root set.
116
119
  pub(super) const CHROME_TRUST_ANCHORS: &[u8] = &chromium_roots::encoded_trust_anchor_ids();
117
120
 
121
+ // Chrome 152/153 iterate a salted flat_hash_set held by SSLClientContext, rather than
122
+ // the root store's source order. Generate an order once per native configuration;
123
+ // connections using that client configuration retain the order.
124
+ // https://chromium.googlesource.com/chromium/src/+/f7b831a4e249fbd98eae4b391c69f70300a849d1/net/ssl/ssl_config_service.cc
125
+ pub(super) fn shuffled_trust_anchors(mut random: impl FnMut() -> u64) -> Vec<u8> {
126
+ let mut ids = chromium_roots::trust_anchor_ids();
127
+ for index in (1..ids.len()).rev() {
128
+ let bound = (index + 1) as u64;
129
+ let limit = u64::MAX - u64::MAX % bound;
130
+ let mut value = random();
131
+ while value >= limit {
132
+ value = random();
133
+ }
134
+ ids.swap(index, (value % bound) as usize);
135
+ }
136
+
137
+ encode_trust_anchors(&ids)
138
+ }
139
+
140
+ // Chrome 154 sorts the raw IDs before adding their length prefixes.
141
+ // https://chromium.googlesource.com/chromium/src/+/731082f0a26ce4b3976c3d82943092f5d13daf13/net/cert/x509_util.cc
142
+ pub(super) fn sorted_trust_anchors() -> Vec<u8> {
143
+ let mut ids = chromium_roots::trust_anchor_ids();
144
+ ids.sort_unstable();
145
+ encode_trust_anchors(&ids)
146
+ }
147
+
148
+ fn encode_trust_anchors(ids: &[&[u8]]) -> Vec<u8> {
149
+ let mut encoded = Vec::with_capacity(CHROME_TRUST_ANCHORS.len());
150
+ for id in ids {
151
+ // encoded_trust_anchor_ids() validates these same IDs at compile time:
152
+ // each length is nonzero and fits in one byte.
153
+ encoded.push(id.len() as u8);
154
+ encoded.extend_from_slice(id);
155
+ }
156
+ encoded
157
+ }
158
+
118
159
  pub const CERTIFICATE_COMPRESSORS: &[&'static dyn CertificateCompressor] = &[&BrotliCompressor];
119
160
 
120
161
  #[derive(TypedBuilder)]
@@ -144,7 +185,7 @@ pub struct ChromeTlsConfig {
144
185
  pre_shared_key: bool,
145
186
 
146
187
  #[builder(default, setter(strip_option))]
147
- trust_anchors: Option<&'static [u8]>,
188
+ trust_anchors: Option<Vec<u8>>,
148
189
 
149
190
  #[builder(default, setter(strip_option))]
150
191
  grease_sigalgs_enabled: Option<bool>,
@@ -181,3 +222,65 @@ impl From<ChromeTlsConfig> for TlsOptions {
181
222
  builder.build()
182
223
  }
183
224
  }
225
+
226
+ #[cfg(test)]
227
+ mod tests {
228
+ use super::*;
229
+ use wreq::IntoEmulation;
230
+
231
+ #[test]
232
+ fn chrome_profiles_retain_trust_anchor_order_when_cloned() {
233
+ for profile in [
234
+ crate::Profile::Chrome152,
235
+ crate::Profile::Chrome153,
236
+ crate::Profile::Chrome154,
237
+ ] {
238
+ let native = profile.into_emulation();
239
+ let cloned = native.clone();
240
+ let ids = native.tls_options.unwrap().trust_anchors.unwrap();
241
+ assert_eq!(ids.len(), CHROME_TRUST_ANCHORS.len());
242
+ assert_eq!(ids, cloned.tls_options.unwrap().trust_anchors.unwrap());
243
+ let decoded = decode_ids(&ids);
244
+ let mut expected = chromium_roots::trust_anchor_ids();
245
+ expected.sort_unstable();
246
+ if profile == crate::Profile::Chrome154 {
247
+ assert_eq!(decoded, expected);
248
+ }
249
+ let mut actual = decoded;
250
+ actual.sort_unstable();
251
+ assert_eq!(actual, expected);
252
+ }
253
+ assert!(
254
+ crate::Profile::Chrome151
255
+ .into_emulation()
256
+ .tls_options
257
+ .unwrap()
258
+ .trust_anchors
259
+ .is_none()
260
+ );
261
+ }
262
+
263
+ #[test]
264
+ fn trust_anchor_shuffle_preserves_ids_without_pinning_the_first() {
265
+ // Choosing index zero at every step rotates the entire list left.
266
+ // Rejecting u64::MAX also covers the unbiased sampling boundary.
267
+ let mut samples = std::iter::once(u64::MAX).chain(std::iter::repeat(0));
268
+ let encoded = shuffled_trust_anchors(|| samples.next().unwrap());
269
+ let decoded = decode_ids(&encoded);
270
+ let mut expected = chromium_roots::trust_anchor_ids();
271
+ expected.rotate_left(1);
272
+ assert_eq!(decoded, expected);
273
+ assert_eq!(encoded.len(), CHROME_TRUST_ANCHORS.len());
274
+ }
275
+
276
+ fn decode_ids(mut remaining: &[u8]) -> Vec<&[u8]> {
277
+ let mut decoded = Vec::new();
278
+ while let Some((&length, rest)) = remaining.split_first() {
279
+ assert_ne!(length, 0);
280
+ let (id, rest) = rest.split_at(usize::from(length));
281
+ decoded.push(id);
282
+ remaining = rest;
283
+ }
284
+ decoded
285
+ }
286
+ }
@@ -1931,7 +1931,8 @@ mod_generator!(
1931
1931
 
1932
1932
  mod_generator!(
1933
1933
  v154,
1934
- v152::build_emulation,
1934
+ tls_options!(9, CURVES_3, sorted_trust_anchors()),
1935
+ http2_options!(3),
1935
1936
  header_initializer_with_zstd_priority,
1936
1937
  [
1937
1938
  (
@@ -216,6 +216,11 @@ impl Platform {
216
216
  /// The `Emulation` struct allows you to configure various aspects of profile and platform
217
217
  /// emulation, including the profile, platform, and whether to enable certain features
218
218
  /// like HTTP/2 or headers.
219
+ ///
220
+ /// Conversion through [`wreq::IntoEmulation`] creates a native configuration,
221
+ /// including a randomized Trust Anchor ID order for Chrome 152/153 profiles.
222
+ /// Client-level emulation retains that order for subsequent connections.
223
+ /// Chrome 154 sorts the IDs by their raw bytes instead.
219
224
  #[derive(Default, Clone, TypedBuilder)]
220
225
  pub struct Emulation {
221
226
  /// Whether to change the profile (browser/okhttp) information.
@@ -264,6 +264,14 @@ module Wreq
264
264
  # for HTTP/2 and automatic default headers. The actual implementation is
265
265
  # provided by Rust.
266
266
  #
267
+ # Construction generates the native configuration once. For Chrome 152/153,
268
+ # the TLS trust_anchors extension's ID order is randomized at construction;
269
+ # Chrome 154 sorts those IDs by their raw bytes instead. Reusing this object
270
+ # across clients or requests retains that order. Creating a new object generates
271
+ # a new configuration, including a fresh shuffle for Chrome 152/153.
272
+ # This does not freeze other per-handshake randomness or force a new handshake
273
+ # when a request reuses an existing connection.
274
+ #
267
275
  # `profile:` defaults to the library's default profile when omitted.
268
276
  # `platform:` defaults to the library's default platform when omitted.
269
277
  #
@@ -289,6 +297,10 @@ module Wreq
289
297
  # Native fields and methods are set by the extension.
290
298
  # This stub is for documentation only.
291
299
  unless singleton_methods(false).include?(:new)
300
+ # Generates a reusable native configuration. Chrome 152/153 Trust Anchor ID
301
+ # order is chosen here and retained when this object is reused; Chrome 154
302
+ # uses raw-byte sorted order. See {Emulation} for connection semantics.
303
+ #
292
304
  # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate
293
305
  # @param platform [Wreq::Platform, nil] Operating system platform to emulate
294
306
  # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults
data/src/emulate.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  use ::serde::Deserialize;
2
2
  use magnus::{Error, Module, Object, RModule, Ruby, Value, function, method, typed_data::Obj};
3
+ use wreq::IntoEmulation;
3
4
 
4
5
  use crate::options::{NativeOption, Options};
5
6
 
@@ -206,10 +207,13 @@ define_ruby_enum!(
206
207
  IOS => "ios",
207
208
  );
208
209
 
209
- /// A struct to represent the `EmulationOption` class.
210
+ /// Materialized emulation configuration owned by the Ruby object.
211
+ ///
212
+ /// Clients and requests clone this value rather than regenerating profile settings,
213
+ /// preserving configuration-level randomness for the lifetime of the object.
210
214
  #[derive(Clone)]
211
215
  #[magnus::wrap(class = "Wreq::Emulation", free_immediately, size)]
212
- pub struct Emulation(pub wreq_util::Emulation);
216
+ pub struct Emulation(pub wreq::Emulation);
213
217
 
214
218
  // ===== impl Emulation =====
215
219
 
@@ -246,7 +250,8 @@ impl Emulation {
246
250
  )
247
251
  .http2(params.http2.unwrap_or(true))
248
252
  .headers(params.headers.unwrap_or(true))
249
- .build();
253
+ .build()
254
+ .into_emulation();
250
255
 
251
256
  Ok(Self(emulation))
252
257
  }
@@ -1,8 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "test_helper"
4
+ require "socket"
5
+ require "stringio"
6
+ require "timeout"
4
7
 
5
8
  class EmulationTest < Minitest::Test
9
+ def test_reused_emulation_retains_trust_anchors_across_clients_and_requests
10
+ [Wreq::Profile::Chrome152, Wreq::Profile::Chrome153, Wreq::Profile::Chrome154].each do |profile|
11
+ emulation = Wreq::Emulation.new(profile: profile)
12
+ client = Wreq::Client.new(emulation: emulation, no_proxy: true)
13
+ expected = capture_trust_anchors { |url| client.get(url, timeout: 5) }
14
+ refute_empty expected, profile.to_s
15
+
16
+ request_client = Wreq::Client.new(no_proxy: true)
17
+ 2.times do
18
+ actual = capture_trust_anchors do |url|
19
+ request_client.get(url, emulation: emulation, timeout: 5)
20
+ end
21
+ assert_equal expected, actual, "#{profile}: reusing an object must retain its configuration"
22
+ end
23
+ end
24
+ end
25
+
6
26
  def test_all_emulation_device_constants_are_non_nil
7
27
  Wreq::Profile.constants.each do |name|
8
28
  const = Wreq::Profile.const_get(name)
@@ -37,4 +57,60 @@ class EmulationTest < Minitest::Test
37
57
  "#{name} should be Platform, got #{const.inspect}"
38
58
  end
39
59
  end
60
+
61
+ private
62
+
63
+ # Each listener forces a new connection. Stop after ClientHello so this test
64
+ # inspects the extension bytes without depending on certificates or the network.
65
+ def capture_trust_anchors
66
+ server = TCPServer.new("127.0.0.1", 0)
67
+ thread = Thread.new do
68
+ socket = nil
69
+ Timeout.timeout(5) do
70
+ socket = server.accept
71
+ handshake = +"".b
72
+ loop do
73
+ header = read_tls_bytes(socket, 5)
74
+ raise "expected a TLS handshake record" unless header.getbyte(0) == 22
75
+ handshake << read_tls_bytes(socket, header.byteslice(3, 2).unpack1("n"))
76
+ next if handshake.bytesize < 4
77
+ length = handshake.byteslice(1, 3).bytes.reduce(0) { |n, byte| (n << 8) | byte }
78
+ break if handshake.bytesize >= length + 4
79
+ end
80
+ trust_anchors_from_client_hello(handshake)
81
+ end
82
+ ensure
83
+ socket&.close
84
+ end
85
+ thread.report_on_exception = false
86
+
87
+ assert_raises(Wreq::ConnectionError) { yield "https://127.0.0.1:#{server.addr[1]}/" }
88
+ thread.value
89
+ ensure
90
+ server&.close
91
+ thread&.kill if thread&.alive?
92
+ thread&.join
93
+ end
94
+
95
+ def read_tls_bytes(io, length)
96
+ bytes = io.read(length)
97
+ raise EOFError, "truncated ClientHello" unless bytes&.bytesize == length
98
+ bytes
99
+ end
100
+
101
+ def trust_anchors_from_client_hello(handshake)
102
+ io = StringIO.new(handshake)
103
+ raise "expected ClientHello" unless read_tls_bytes(io, 1).unpack1("C") == 1
104
+ read_tls_bytes(io, 3 + 2 + 32) # Handshake length, legacy version and random.
105
+ read_tls_bytes(io, read_tls_bytes(io, 1).unpack1("C")) # Session ID.
106
+ read_tls_bytes(io, read_tls_bytes(io, 2).unpack1("n")) # Cipher suites.
107
+ read_tls_bytes(io, read_tls_bytes(io, 1).unpack1("C")) # Compression methods.
108
+ extensions = StringIO.new(read_tls_bytes(io, read_tls_bytes(io, 2).unpack1("n")))
109
+ until extensions.eof?
110
+ type, length = read_tls_bytes(extensions, 4).unpack("nn")
111
+ data = read_tls_bytes(extensions, length)
112
+ return data if type == 51764
113
+ end
114
+ raise "missing trust_anchors extension"
115
+ end
40
116
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wreq
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.16
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SearchApi