maxmind-db-rust 0.4.0 → 0.5.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: 854691c99c81b7d9574c780a7e10f80eea69788bb4dc95cce41361e5163d6f28
4
- data.tar.gz: '086f7efa6e3620e3fd15d66b87964076647e7510551d58d98e4f484c69400173'
3
+ metadata.gz: 477024cc8d20f8e13fa22fa0e6d8a94554c91f16812e81ef2154ae5cc821739c
4
+ data.tar.gz: 4a5b94f8b821af36bed45f0b164ea1c5061c734a3827afc8f0ed059ec986c4b0
5
5
  SHA512:
6
- metadata.gz: f20905fca81e3742ff75da2e5758fbd198d61dd5c7c82b23dd8ed9a731bf7a46dc707c6660590adb895e2c022b6f6d07edb7916eab774d4461faccd2de66ec2e
7
- data.tar.gz: 888d4623247bf07a0c8058c669230032ebcef98b0efcc03920f1196cb1695361f2a868250ffeafb6fe3ed7913038b38f4b419d9ef5e3a1d5f3caecd0b1ec1bb0
6
+ metadata.gz: ad9547ef7bf1d3371325d7fd519f78f0d01f302e5fecc4e5181d9a88e57aa1ebed4c51b1f6ae08491133aab05a63ad31516bae33051a78362308ec77ece1e204
7
+ data.tar.gz: f5be53fe4952c00ad82c3824a0b17aa7ae420a9580261686847f684ab8834fbbaa6d5b316405f6a70ee14fc9fd25717fcb367d49af6c5e9d29fb10ed33e2736b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-06-14
9
+
10
+ ### Added
11
+
12
+ - Added `MODE_FILE` as an official-gem compatibility alias for path-backed
13
+ memory-mapped readers.
14
+ - Added `MODE_PARAM_IS_BUFFER` for constructing readers from Ruby strings
15
+ containing database bytes.
16
+ - Added `Reader#get_path` and `Reader#get_many_path` for selective path lookups.
17
+ - Added `Reader#get_many` for batch lookups over arrays and enumerables.
18
+ - Added Enumerator return support for `Reader#each` when called without a block.
19
+ - Added `Reader#inspect` with closed state and database IP version.
20
+ - Added benchmark tooling for comparing git refs and measuring Ruby object
21
+ allocations.
22
+ - Added official gem parity tests, compatibility audit tests, bad data corpus
23
+ tests, and reader concurrency stress tests.
24
+
25
+ ### Changed
26
+
27
+ - Upgraded the `maxminddb` crate to 0.28.1.
28
+ - Improved IPv4 string lookup performance with a strict fast-path parser.
29
+ - Streamed non-array `get_many` and `get_many_path` inputs instead of
30
+ materializing enumerables.
31
+ - Cached parsed lookup paths per reader to reduce repeated path parsing.
32
+ - Simplified reader open mode parsing and centralized lookup error handling.
33
+ - Documented Rust extension safety invariants and `Send` requirements.
34
+
35
+ ### Fixed
36
+
37
+ - Fixed source gem native extension installation so `require "maxmind/db/rust"`
38
+ works after installing the source gem.
39
+ - Removed an unsafe reader iterator transmute.
40
+ - Improved invalid database error construction consistency across reader open
41
+ and iteration paths.
42
+ - Fixed dead test assertions and made adapted MaxMind tests independent of the
43
+ current working directory.
44
+
45
+ ### Security
46
+
47
+ - Pinned GitHub Actions to commit SHAs.
48
+ - Restricted workflow permissions and disabled persisted checkout credentials.
49
+ - Added a zizmor workflow and source-gem install smoke test to release checks.
50
+
8
51
  ## [0.4.0] - 2026-04-25
9
52
 
10
53
  ### Performance
data/README.md CHANGED
@@ -13,9 +13,10 @@ It keeps the API close to the official `maxmind-db` gem while adding Rust-backed
13
13
  - Rust implementation focused on fast lookups
14
14
  - API modeled after the official `maxmind-db` gem
15
15
  - Thread-safe lookups
16
- - Supports MMAP and in-memory modes
16
+ - Supports file-backed, MMAP, in-memory, and buffer-backed modes
17
17
  - Includes network iteration support
18
18
  - Accepts both `String` and `IPAddr` inputs
19
+ - Includes selective path lookup and batch lookup extensions
19
20
 
20
21
  ## Installation
21
22
 
@@ -81,6 +82,26 @@ puts "Prefix length: #{prefix_length}"
81
82
  reader.close
82
83
  ```
83
84
 
85
+ ### Selective and Batch Lookups
86
+
87
+ ```ruby
88
+ require 'maxmind/db/rust'
89
+
90
+ reader = MaxMind::DB::Rust::Reader.new('GeoIP2-City.mmdb')
91
+
92
+ # Decode one field without materializing the full record.
93
+ iso_code = reader.get_path('8.8.8.8', ['country', 'iso_code'])
94
+
95
+ # Batch full-record lookups.
96
+ ips = ['8.8.8.8', '1.1.1.1', '208.67.222.222']
97
+ records = reader.get_many(ips)
98
+
99
+ # Batch one-field lookups.
100
+ iso_codes = reader.get_many_path(ips, ['country', 'iso_code'])
101
+
102
+ reader.close
103
+ ```
104
+
84
105
  ### Using IPAddr Objects
85
106
 
86
107
  ```ruby
@@ -112,11 +133,24 @@ reader = MaxMind::DB::Rust::Reader.new(
112
133
  mode: MaxMind::DB::Rust::MODE_MMAP
113
134
  )
114
135
 
136
+ # MODE_FILE: Official-gem compatibility alias for path-backed MMAP
137
+ reader = MaxMind::DB::Rust::Reader.new(
138
+ 'GeoIP2-City.mmdb',
139
+ mode: MaxMind::DB::Rust::MODE_FILE
140
+ )
141
+
115
142
  # MODE_MEMORY: Load entire database into memory
116
143
  reader = MaxMind::DB::Rust::Reader.new(
117
144
  'GeoIP2-City.mmdb',
118
145
  mode: MaxMind::DB::Rust::MODE_MEMORY
119
146
  )
147
+
148
+ # MODE_PARAM_IS_BUFFER: Read from a String containing database bytes
149
+ buffer = File.binread('GeoIP2-City.mmdb')
150
+ reader = MaxMind::DB::Rust::Reader.new(
151
+ buffer,
152
+ mode: MaxMind::DB::Rust::MODE_PARAM_IS_BUFFER
153
+ )
120
154
  ```
121
155
 
122
156
  ### Accessing Metadata
@@ -176,15 +210,15 @@ reader.close
176
210
 
177
211
  ### `MaxMind::DB::Rust::Reader`
178
212
 
179
- #### `new(database_path, options = {})`
213
+ #### `new(database, options = {})`
180
214
 
181
215
  Create a new Reader instance.
182
216
 
183
217
  **Parameters:**
184
218
 
185
- - `database_path` (String): Path to the MaxMind DB file
219
+ - `database` (String): Path to the MaxMind DB file, or database bytes when using `:MODE_PARAM_IS_BUFFER`
186
220
  - `options` (Hash): Optional configuration
187
- - `:mode` (Symbol): One of `:MODE_AUTO`, `:MODE_MEMORY`, or `:MODE_MMAP`
221
+ - `:mode` (Symbol): One of `:MODE_AUTO`, `:MODE_FILE`, `:MODE_MEMORY`, `:MODE_MMAP`, or `:MODE_PARAM_IS_BUFFER`
188
222
 
189
223
  **Returns:** Reader instance
190
224
 
@@ -208,6 +242,17 @@ Look up an IP address in the database.
208
242
  - `ArgumentError`: If looking up IPv6 in an IPv4-only database
209
243
  - `MaxMind::DB::Rust::InvalidDatabaseError`: If the database is corrupt
210
244
 
245
+ #### `get_path(ip_address, path)`
246
+
247
+ Look up an IP address and return only the value at `path`.
248
+
249
+ **Parameters:**
250
+
251
+ - `ip_address` (String or IPAddr): The IP address to look up
252
+ - `path` (Array): String map keys and Integer array indexes. Negative indexes count from the end.
253
+
254
+ **Returns:** The value at the path, or `nil` if the record or path is not found
255
+
211
256
  #### `get_with_prefix_length(ip_address)`
212
257
 
213
258
  Look up an IP address and return the prefix length.
@@ -218,6 +263,27 @@ Look up an IP address and return the prefix length.
218
263
 
219
264
  **Returns:** Array `[record, prefix_length]` where record is a Hash or `nil`
220
265
 
266
+ #### `get_many(ip_addresses)`
267
+
268
+ Look up multiple IP addresses.
269
+
270
+ **Parameters:**
271
+
272
+ - `ip_addresses` (Array or Enumerable): IP address strings or IPAddr objects
273
+
274
+ **Returns:** Array of record values in input order
275
+
276
+ #### `get_many_path(ip_addresses, path)`
277
+
278
+ Look up one path for multiple IP addresses.
279
+
280
+ **Parameters:**
281
+
282
+ - `ip_addresses` (Array or Enumerable): IP address strings or IPAddr objects
283
+ - `path` (Array): String map keys and Integer array indexes
284
+
285
+ **Returns:** Array of path values in input order
286
+
221
287
  #### `metadata()`
222
288
 
223
289
  Get metadata about the database.
@@ -269,8 +335,10 @@ Metadata attributes:
269
335
  ### Constants
270
336
 
271
337
  - `MaxMind::DB::Rust::MODE_AUTO` - Automatically choose the best mode (uses MMAP)
338
+ - `MaxMind::DB::Rust::MODE_FILE` - Official-gem compatibility alias for path-backed MMAP
272
339
  - `MaxMind::DB::Rust::MODE_MEMORY` - Load entire database into memory
273
340
  - `MaxMind::DB::Rust::MODE_MMAP` - Use memory-mapped file I/O (recommended)
341
+ - `MaxMind::DB::Rust::MODE_PARAM_IS_BUFFER` - Read database bytes from a Ruby String
274
342
 
275
343
  ### Exceptions
276
344
 
@@ -278,26 +346,26 @@ Metadata attributes:
278
346
 
279
347
  ## Comparison with Official Gem
280
348
 
281
- | Feature | maxmind-db (official) | maxmind-db-rust (this gem) |
282
- | ---------------- | --------------------- | ------------------------------------------ |
283
- | Implementation | Pure Ruby | Rust with Ruby bindings |
284
- | Performance | Baseline | Faster lookup throughput in our benchmarks |
285
- | API | MaxMind::DB | MaxMind::DB::Rust |
286
- | MODE_FILE | ✓ | |
287
- | MODE_MEMORY | ✓ | ✓ |
288
- | MODE_AUTO | ✓ | ✓ |
289
- | MODE_MMAP | | ✓ |
290
- | Iterator support | ✗ | ✓ |
291
- | Thread-safe | | ✓ |
349
+ | Feature | maxmind-db (official) | maxmind-db-rust (this gem) |
350
+ | -------------------- | --------------------- | ------------------------------------------ |
351
+ | Implementation | Pure Ruby | Rust with Ruby bindings |
352
+ | Performance | Baseline | Faster lookup throughput in our benchmarks |
353
+ | API | MaxMind::DB | MaxMind::DB::Rust |
354
+ | MODE_FILE | ✓ | |
355
+ | MODE_MEMORY | ✓ | ✓ |
356
+ | MODE_AUTO | ✓ | ✓ |
357
+ | MODE_PARAM_IS_BUFFER | | ✓ |
358
+ | MODE_MMAP | ✗ | ✓ |
359
+ | Iterator support | | ✓ |
360
+ | Thread-safe | ✓ | ✓ |
292
361
 
293
362
  ## Performance
294
363
 
295
364
  Lookup performance depends on hardware, Ruby version, database, and workload.
296
365
 
297
366
  - In this project’s random-lookup benchmarks, this gem is consistently faster than the official Ruby implementation.
298
- - On `/var/lib/GeoIP/GeoIP2-City.mmdb` in this environment, random lookup throughput was about `47x` higher than the official gem.
299
367
  - `MODE_MMAP` and `MODE_MEMORY` both perform well; which is faster can vary by environment.
300
- - For reproducible numbers on your own data, run `benchmark/compare_lookups.rb` against your database.
368
+ - For current, reproducible numbers on your own data and Ruby version, run `benchmark/compare_lookups.rb` against your database.
301
369
  - Safe for concurrent lookups across threads.
302
370
 
303
371
  ## Development
@@ -15,7 +15,7 @@ crate-type = ["cdylib"]
15
15
  arc-swap = "1.9"
16
16
  ipnetwork = "0.21"
17
17
  magnus = "0.8"
18
- maxminddb = { version = "0.28", features = ["unsafe-str-decode"] }
18
+ maxminddb = { version = "0.28.1", features = ["unsafe-str-decode"] }
19
19
  memmap2 = "0.9"
20
20
  rustc-hash = "2.1"
21
21
  serde = "1.0"
@@ -3,4 +3,4 @@
3
3
  require 'mkmf'
4
4
  require 'rb_sys/mkmf'
5
5
 
6
- create_rust_makefile('maxmind_db_rust')
6
+ create_rust_makefile('maxmind/db/maxmind_db_rust')
@@ -10,7 +10,7 @@ module MaxMind
10
10
  # - Reader class
11
11
  # - Metadata class
12
12
  # - InvalidDatabaseError exception
13
- # - MODE_AUTO, MODE_MEMORY, MODE_MMAP constants
13
+ # - MODE_AUTO, MODE_FILE, MODE_MEMORY, MODE_MMAP, MODE_PARAM_IS_BUFFER constants
14
14
  end
15
15
  end
16
16
  end
@@ -5,26 +5,26 @@ use ::maxminddb as maxminddb_crate;
5
5
  use arc_swap::{ArcSwapOption, Guard};
6
6
  use ipnetwork::IpNetwork;
7
7
  use magnus::{
8
- error::Error, prelude::*, scan_args::get_kwargs, scan_args::scan_args, ExceptionClass,
9
- IntoValue, RArray, RClass, RHash, RModule, RString, Symbol, Value,
8
+ error::Error, prelude::*, scan_args::get_kwargs, scan_args::scan_args, typed_data::Obj,
9
+ ExceptionClass, IntoValue, RArray, RClass, RHash, RModule, RString, Symbol, Value,
10
10
  };
11
- use maxminddb_crate::{MaxMindDbError, Reader as MaxMindReader, Within};
11
+ use maxminddb_crate::{MaxMindDbError, PathElement, Reader as MaxMindReader, Within};
12
12
  use memmap2::Mmap;
13
13
  use rustc_hash::FxHasher;
14
14
  use serde::de::{self, Deserialize, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
15
15
  use std::{
16
16
  cell::{OnceCell, RefCell},
17
- collections::BTreeMap,
17
+ collections::{BTreeMap, VecDeque},
18
18
  fmt,
19
19
  fs::File,
20
20
  hash::{Hash, Hasher},
21
21
  io::Read as IoRead,
22
- net::IpAddr,
22
+ net::{IpAddr, Ipv4Addr},
23
23
  path::Path,
24
24
  str::FromStr,
25
25
  sync::{
26
26
  atomic::{AtomicBool, Ordering},
27
- Arc,
27
+ Arc, Mutex,
28
28
  },
29
29
  };
30
30
 
@@ -37,6 +37,7 @@ const MAP_KEY_ROOTS_CONST: &str = "__MAP_KEY_ROOTS__";
37
37
  const STRING_CACHE_MAX: usize = 4096;
38
38
  const STRING_CACHE_MIN_LEN: usize = 2;
39
39
  const STRING_CACHE_MAX_LEN: usize = 64;
40
+ const PATH_CACHE_MAX_ENTRIES: usize = 64;
40
41
 
41
42
  #[derive(Default)]
42
43
  struct StringCacheEntry {
@@ -319,6 +320,42 @@ enum ReaderSource {
319
320
  Memory(MaxMindReader<Vec<u8>>),
320
321
  }
321
322
 
323
+ #[derive(Copy, Clone)]
324
+ enum OpenMode {
325
+ Mmap,
326
+ Memory,
327
+ Buffer,
328
+ }
329
+
330
+ impl OpenMode {
331
+ fn from_symbol(mode: Symbol, ruby: &magnus::Ruby) -> Result<Self, Error> {
332
+ let mode_name = mode.name()?;
333
+ match mode_name.as_ref() {
334
+ // MODE_FILE is the official gem's file-backed mode; use the
335
+ // existing mmap reader for the same path-backed behavior.
336
+ "MODE_AUTO" | "MODE_FILE" | "MODE_MMAP" => Ok(Self::Mmap),
337
+ "MODE_MEMORY" => Ok(Self::Memory),
338
+ "MODE_PARAM_IS_BUFFER" => Ok(Self::Buffer),
339
+ _ => Err(Error::new(
340
+ ruby.exception_arg_error(),
341
+ format!("Unsupported mode: {}", mode_name),
342
+ )),
343
+ }
344
+ }
345
+ }
346
+
347
+ #[derive(PartialEq, Eq)]
348
+ enum OwnedPathElement {
349
+ Key(String),
350
+ Index(usize),
351
+ IndexFromEnd(usize),
352
+ }
353
+
354
+ struct CachedPath {
355
+ hash: u64,
356
+ elements: Arc<[OwnedPathElement]>,
357
+ }
358
+
322
359
  impl ReaderSource {
323
360
  #[inline]
324
361
  fn lookup(
@@ -351,6 +388,18 @@ impl ReaderSource {
351
388
  Ok((result, prefix_len))
352
389
  }
353
390
 
391
+ #[inline]
392
+ fn lookup_path(
393
+ &self,
394
+ ip: IpAddr,
395
+ path_elements: &[PathElement<'_>],
396
+ ) -> Result<Option<RubyDecodedValue>, maxminddb_crate::MaxMindDbError> {
397
+ match self {
398
+ ReaderSource::Mmap(reader) => reader.lookup(ip)?.decode_path(path_elements),
399
+ ReaderSource::Memory(reader) => reader.lookup(ip)?.decode_path(path_elements),
400
+ }
401
+ }
402
+
354
403
  #[inline]
355
404
  fn metadata(&self) -> &maxminddb_crate::Metadata {
356
405
  match self {
@@ -360,31 +409,25 @@ impl ReaderSource {
360
409
  }
361
410
 
362
411
  #[inline]
363
- fn within(&self, network: IpNetwork) -> Result<ReaderWithin, MaxMindDbError> {
412
+ fn within(&self, network: IpNetwork) -> Result<ReaderWithin<'_>, MaxMindDbError> {
364
413
  match self {
365
- ReaderSource::Mmap(reader) => {
366
- let iter = reader.within(network, Default::default())?;
367
- Ok(ReaderWithin::Mmap(unsafe {
368
- std::mem::transmute::<Within<'_, Mmap>, Within<'static, Mmap>>(iter)
369
- }))
370
- }
371
- ReaderSource::Memory(reader) => {
372
- let iter = reader.within(network, Default::default())?;
373
- Ok(ReaderWithin::Memory(unsafe {
374
- std::mem::transmute::<Within<'_, Vec<u8>>, Within<'static, Vec<u8>>>(iter)
375
- }))
376
- }
414
+ ReaderSource::Mmap(reader) => Ok(ReaderWithin::Mmap(
415
+ reader.within(network, Default::default())?,
416
+ )),
417
+ ReaderSource::Memory(reader) => Ok(ReaderWithin::Memory(
418
+ reader.within(network, Default::default())?,
419
+ )),
377
420
  }
378
421
  }
379
422
  }
380
423
 
381
424
  /// Wrapper enum for Within iterators
382
- enum ReaderWithin {
383
- Mmap(Within<'static, Mmap>),
384
- Memory(Within<'static, Vec<u8>>),
425
+ enum ReaderWithin<'reader> {
426
+ Mmap(Within<'reader, Mmap>),
427
+ Memory(Within<'reader, Vec<u8>>),
385
428
  }
386
429
 
387
- impl ReaderWithin {
430
+ impl ReaderWithin<'_> {
388
431
  fn next(&mut self) -> Option<Result<(IpNetwork, RubyDecodedValue), MaxMindDbError>> {
389
432
  match self {
390
433
  ReaderWithin::Mmap(iter) => next_within_result(iter),
@@ -407,7 +450,7 @@ fn prefix_len_for_ip_network(ip: IpAddr, network: IpNetwork) -> usize {
407
450
 
408
451
  #[inline]
409
452
  fn next_within_result<S: AsRef<[u8]>>(
410
- iter: &mut Within<'static, S>,
453
+ iter: &mut Within<'_, S>,
411
454
  ) -> Option<Result<(IpNetwork, RubyDecodedValue), MaxMindDbError>> {
412
455
  loop {
413
456
  match iter.next() {
@@ -501,6 +544,10 @@ impl Metadata {
501
544
  }
502
545
  }
503
546
 
547
+ // SAFETY: Metadata stores only owned Rust values copied out of the database
548
+ // metadata. It contains no Ruby VALUE handles or borrowed database/source data,
549
+ // so moving it between Ruby-managed threads cannot invalidate GC or lifetime
550
+ // assumptions.
504
551
  unsafe impl Send for Metadata {}
505
552
 
506
553
  /// A Ruby wrapper around the MaxMind DB reader
@@ -509,6 +556,7 @@ unsafe impl Send for Metadata {}
509
556
  struct Reader {
510
557
  reader: Arc<ArcSwapOption<ReaderSource>>,
511
558
  closed: Arc<AtomicBool>,
559
+ path_cache: Arc<Mutex<VecDeque<CachedPath>>>,
512
560
  ip_version: u16,
513
561
  }
514
562
 
@@ -516,7 +564,7 @@ impl Reader {
516
564
  fn new(args: &[Value]) -> Result<Self, Error> {
517
565
  let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby method");
518
566
 
519
- let args = scan_args::<(String,), (), (), (), _, ()>(args)?;
567
+ let args = scan_args::<(Value,), (), (), (), _, ()>(args)?;
520
568
  let (database,) = args.required;
521
569
  let kw = get_kwargs::<_, (), (Option<Symbol>,), ()>(args.keywords, &[], &["mode"])?;
522
570
  let (mode,) = kw.optional;
@@ -524,29 +572,13 @@ impl Reader {
524
572
  // Parse mode from options hash
525
573
  let mode: Symbol = mode.unwrap_or_else(|| ruby.to_symbol("MODE_AUTO"));
526
574
 
527
- let mode_str = mode.name()?;
528
- let mode_str: &str = &mode_str;
529
-
530
- // Determine actual mode to use
531
- let actual_mode = match mode_str {
532
- "MODE_AUTO" | "MODE_MMAP" => "MMAP",
533
- "MODE_MEMORY" => "MEMORY",
534
- _ => {
535
- return Err(Error::new(
536
- ruby.exception_arg_error(),
537
- format!("Unsupported mode: {}", mode_str),
538
- ))
539
- }
540
- };
575
+ let open_mode = OpenMode::from_symbol(mode, &ruby)?;
541
576
 
542
577
  // Open database with appropriate mode
543
- match actual_mode {
544
- "MMAP" => open_database_mmap(&database),
545
- "MEMORY" => open_database_memory(&database),
546
- _ => Err(Error::new(
547
- ruby.exception_arg_error(),
548
- format!("Invalid mode: {}", actual_mode),
549
- )),
578
+ match open_mode {
579
+ OpenMode::Mmap => open_database_mmap(&database_path(database)?),
580
+ OpenMode::Memory => open_database_memory(&database_path(database)?),
581
+ OpenMode::Buffer => open_database_buffer(database_buffer(database)?),
550
582
  }
551
583
  }
552
584
 
@@ -558,32 +590,28 @@ impl Reader {
558
590
  let reader_option = guard.as_ref();
559
591
  let reader = reader_option.as_ref().unwrap();
560
592
 
561
- // Parse IP address
562
- let parsed_ip = parse_ip_address_fast(ip_address, &ruby)?;
593
+ let parsed_ip = self.parse_lookup_ip(ip_address, &ruby)?;
563
594
 
564
- if self.ip_version == 4 && matches!(parsed_ip, IpAddr::V6(_)) {
565
- return Err(Error::new(
566
- ruby.exception_arg_error(),
567
- ipv6_in_ipv4_error(&parsed_ip),
568
- ));
569
- }
595
+ lookup_result_to_value(&ruby, reader.lookup(parsed_ip), "Database lookup failed")
596
+ }
570
597
 
571
- // Perform lookup
572
- match reader.lookup(parsed_ip) {
573
- Ok(Some(data)) => Ok(data.into_value()),
574
- Ok(None) => Ok(ruby.qnil().as_value()),
575
- Err(MaxMindDbError::InvalidDatabase { .. }) | Err(MaxMindDbError::Io(_)) => {
576
- Err(Error::new(
577
- ExceptionClass::from_value(invalid_database_error().as_value())
578
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
579
- ERR_BAD_DATA,
580
- ))
581
- }
582
- Err(e) => Err(Error::new(
583
- ruby.exception_runtime_error(),
584
- format!("Database lookup failed: {}", e),
585
- )),
586
- }
598
+ #[inline]
599
+ fn get_path(&self, ip_address: Value, path: Value) -> Result<Value, Error> {
600
+ let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby method");
601
+
602
+ let guard = self.get_reader(&ruby)?;
603
+ let reader_option = guard.as_ref();
604
+ let reader = reader_option.as_ref().unwrap();
605
+
606
+ let parsed_ip = self.parse_lookup_ip(ip_address, &ruby)?;
607
+ let owned_path = self.parse_path(path, &ruby)?;
608
+ let path_elements = path_elements_from_owned_path(owned_path.as_ref());
609
+
610
+ lookup_result_to_value(
611
+ &ruby,
612
+ reader.lookup_path(parsed_ip, &path_elements),
613
+ "Database lookup failed",
614
+ )
587
615
  }
588
616
 
589
617
  #[inline]
@@ -594,42 +622,65 @@ impl Reader {
594
622
  let reader_option = guard.as_ref();
595
623
  let reader = reader_option.as_ref().unwrap();
596
624
 
597
- // Parse IP address
598
- let parsed_ip = parse_ip_address_fast(ip_address, &ruby)?;
599
-
600
- if self.ip_version == 4 && matches!(parsed_ip, IpAddr::V6(_)) {
601
- return Err(Error::new(
602
- ruby.exception_arg_error(),
603
- ipv6_in_ipv4_error(&parsed_ip),
604
- ));
605
- }
625
+ let parsed_ip = self.parse_lookup_ip(ip_address, &ruby)?;
606
626
 
607
627
  // Perform lookup with prefix
608
- match reader.lookup_prefix(parsed_ip) {
609
- Ok((Some(data), prefix)) => {
610
- let arr = ruby.ary_new();
611
- arr.push(data.into_value())?;
612
- arr.push(prefix.into_value_with(&ruby))?;
613
- Ok(arr)
614
- }
615
- Ok((None, prefix)) => {
616
- let arr = ruby.ary_new();
617
- arr.push(ruby.qnil().as_value())?;
618
- arr.push(prefix.into_value_with(&ruby))?;
619
- Ok(arr)
628
+ lookup_prefix_result_to_array(
629
+ &ruby,
630
+ reader.lookup_prefix(parsed_ip),
631
+ "Database lookup failed",
632
+ )
633
+ }
634
+
635
+ fn get_many(&self, ips: Value) -> Result<RArray, Error> {
636
+ let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby method");
637
+
638
+ let guard = self.get_reader(&ruby)?;
639
+ let reader_option = guard.as_ref();
640
+ let reader = reader_option.as_ref().unwrap();
641
+
642
+ if let Ok(ip_array) = RArray::try_convert(ips) {
643
+ let results = ruby.ary_new_capa(ip_array.len());
644
+ for index in 0..ip_array.len() {
645
+ let ip = ip_array.entry::<Value>(index as isize)?;
646
+ results.push(self.lookup_ip_value(&ruby, reader, ip)?)?;
620
647
  }
621
- Err(MaxMindDbError::InvalidDatabase { .. }) | Err(MaxMindDbError::Io(_)) => {
622
- Err(Error::new(
623
- ExceptionClass::from_value(invalid_database_error().as_value())
624
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
625
- ERR_BAD_DATA,
626
- ))
648
+ return Ok(results);
649
+ }
650
+
651
+ ensure_enumerable(ips, &ruby, "ips must be an Array or Enumerable")?;
652
+ let results = ruby.ary_new();
653
+ for ip in ips.enumeratorize("each", ()) {
654
+ results.push(self.lookup_ip_value(&ruby, reader, ip?)?)?;
655
+ }
656
+ Ok(results)
657
+ }
658
+
659
+ fn get_many_path(&self, ips: Value, path: Value) -> Result<RArray, Error> {
660
+ let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby method");
661
+
662
+ let guard = self.get_reader(&ruby)?;
663
+ let reader_option = guard.as_ref();
664
+ let reader = reader_option.as_ref().unwrap();
665
+
666
+ let owned_path = self.parse_path(path, &ruby)?;
667
+ let path_elements = path_elements_from_owned_path(owned_path.as_ref());
668
+
669
+ if let Ok(ip_array) = RArray::try_convert(ips) {
670
+ let results = ruby.ary_new_capa(ip_array.len());
671
+ for index in 0..ip_array.len() {
672
+ let ip = ip_array.entry::<Value>(index as isize)?;
673
+ results.push(self.lookup_ip_path_value(&ruby, reader, ip, &path_elements)?)?;
627
674
  }
628
- Err(e) => Err(Error::new(
629
- ruby.exception_runtime_error(),
630
- format!("Database lookup failed: {}", e),
631
- )),
675
+ return Ok(results);
632
676
  }
677
+
678
+ ensure_enumerable(ips, &ruby, "ips must be an Array or Enumerable")?;
679
+ let results = ruby.ary_new();
680
+ for ip in ips.enumeratorize("each", ()) {
681
+ results.push(self.lookup_ip_path_value(&ruby, reader, ip?, &path_elements)?)?;
682
+ }
683
+ Ok(results)
633
684
  }
634
685
 
635
686
  fn metadata(&self) -> Result<Metadata, Error> {
@@ -664,19 +715,25 @@ impl Reader {
664
715
  self.closed.load(Ordering::Acquire)
665
716
  }
666
717
 
667
- fn each(&self, args: &[Value]) -> Result<Value, Error> {
668
- let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby method");
718
+ fn inspect(&self) -> String {
719
+ format!(
720
+ "#<MaxMind::DB::Rust::Reader:0x{:x} @closed={} @ip_version={}>",
721
+ self as *const Self as usize,
722
+ self.closed(),
723
+ self.ip_version,
724
+ )
725
+ }
669
726
 
670
- let guard = self.get_reader(&ruby)?;
727
+ fn each(ruby: &magnus::Ruby, rb_self: Obj<Self>, args: &[Value]) -> Result<Value, Error> {
728
+ let reader_self = &*rb_self;
729
+
730
+ let guard = reader_self.get_reader(ruby)?;
671
731
  let reader_option = guard.as_ref();
672
732
  let reader = reader_option.as_ref().unwrap();
673
733
 
674
734
  // If no block given, return enumerator
675
735
  if !ruby.block_given() {
676
- return Err(Error::new(
677
- ruby.exception_runtime_error(),
678
- "Enumerator support not yet implemented, please provide a block",
679
- ));
736
+ return Ok(rb_self.enumeratorize("each", args).as_value());
680
737
  }
681
738
 
682
739
  let ip_version = reader.metadata().ip_version;
@@ -747,13 +804,9 @@ impl Reader {
747
804
  ));
748
805
  }
749
806
 
750
- let mut iter = reader.within(network).map_err(|e| {
751
- Error::new(
752
- ExceptionClass::from_value(invalid_database_error().as_value())
753
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
754
- format!("Failed to iterate: {}", e),
755
- )
756
- })?;
807
+ let mut iter = reader
808
+ .within(network)
809
+ .map_err(|e| invalid_database_exception(&format!("Failed to iterate: {}", e)))?;
757
810
  // Get IPAddr class
758
811
  let ipaddr_class = ruby.class_object().const_get::<_, RClass>("IPAddr")?;
759
812
 
@@ -769,12 +822,10 @@ impl Reader {
769
822
  let values = (ipaddr, data.into_value());
770
823
  ruby.yield_values::<(Value, Value), Value>(values)?;
771
824
  }
772
- Err(MaxMindDbError::InvalidDatabase { .. }) | Err(MaxMindDbError::Io(_)) => {
773
- return Err(Error::new(
774
- ExceptionClass::from_value(invalid_database_error().as_value())
775
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
776
- ERR_BAD_DATA,
777
- ));
825
+ Err(MaxMindDbError::InvalidDatabase { .. })
826
+ | Err(MaxMindDbError::Decoding { .. })
827
+ | Err(MaxMindDbError::Io(_)) => {
828
+ return Err(invalid_database_exception(ERR_BAD_DATA));
778
829
  }
779
830
  Err(e) => {
780
831
  return Err(Error::new(
@@ -796,8 +847,114 @@ impl Reader {
796
847
  }
797
848
  Ok(guard)
798
849
  }
850
+
851
+ #[inline]
852
+ fn parse_lookup_ip(&self, ip_address: Value, ruby: &magnus::Ruby) -> Result<IpAddr, Error> {
853
+ let parsed_ip = parse_ip_address_fast(ip_address, ruby)?;
854
+ self.validate_lookup_ip(parsed_ip, ruby)
855
+ }
856
+
857
+ #[inline]
858
+ fn validate_lookup_ip(&self, parsed_ip: IpAddr, ruby: &magnus::Ruby) -> Result<IpAddr, Error> {
859
+ if self.ip_version == 4 && matches!(parsed_ip, IpAddr::V6(_)) {
860
+ Err(Error::new(
861
+ ruby.exception_arg_error(),
862
+ ipv6_in_ipv4_error(&parsed_ip),
863
+ ))
864
+ } else {
865
+ Ok(parsed_ip)
866
+ }
867
+ }
868
+
869
+ #[inline]
870
+ fn lookup_ip_value(
871
+ &self,
872
+ ruby: &magnus::Ruby,
873
+ reader: &ReaderSource,
874
+ ip: Value,
875
+ ) -> Result<Value, Error> {
876
+ let parsed_ip = self.parse_lookup_ip(ip, ruby)?;
877
+ lookup_result_to_value(ruby, reader.lookup(parsed_ip), "Database lookup failed")
878
+ }
879
+
880
+ #[inline]
881
+ fn lookup_ip_path_value(
882
+ &self,
883
+ ruby: &magnus::Ruby,
884
+ reader: &ReaderSource,
885
+ ip: Value,
886
+ path_elements: &[PathElement<'_>],
887
+ ) -> Result<Value, Error> {
888
+ let parsed_ip = self.parse_lookup_ip(ip, ruby)?;
889
+ lookup_result_to_value(
890
+ ruby,
891
+ reader.lookup_path(parsed_ip, path_elements),
892
+ "Database lookup failed",
893
+ )
894
+ }
895
+
896
+ fn parse_path(
897
+ &self,
898
+ path: Value,
899
+ ruby: &magnus::Ruby,
900
+ ) -> Result<Arc<[OwnedPathElement]>, Error> {
901
+ let path = path_array(path, ruby)?;
902
+ let hash = path_cache_hash(path, ruby)?;
903
+
904
+ if let Some(cached) = self.cached_path(path, hash)? {
905
+ return Ok(cached);
906
+ }
907
+
908
+ let parsed_path: Arc<[OwnedPathElement]> = parse_path_array(path, ruby)?.into();
909
+ self.store_cached_path(hash, parsed_path.clone());
910
+ Ok(parsed_path)
911
+ }
912
+
913
+ fn cached_path(
914
+ &self,
915
+ path: RArray,
916
+ hash: u64,
917
+ ) -> Result<Option<Arc<[OwnedPathElement]>>, Error> {
918
+ let candidates = match self.path_cache.lock() {
919
+ Ok(cache) => cache
920
+ .iter()
921
+ .filter(|entry| entry.hash == hash && entry.elements.len() == path.len())
922
+ .map(|entry| entry.elements.clone())
923
+ .collect::<Vec<_>>(),
924
+ Err(_) => return Ok(None),
925
+ };
926
+
927
+ for candidate in candidates {
928
+ if path_matches_cached(path, candidate.as_ref())? {
929
+ return Ok(Some(candidate));
930
+ }
931
+ }
932
+
933
+ Ok(None)
934
+ }
935
+
936
+ fn store_cached_path(&self, hash: u64, elements: Arc<[OwnedPathElement]>) {
937
+ if let Ok(mut cache) = self.path_cache.lock() {
938
+ if cache
939
+ .iter()
940
+ .any(|entry| entry.hash == hash && entry.elements.as_ref() == elements.as_ref())
941
+ {
942
+ return;
943
+ }
944
+
945
+ cache.push_back(CachedPath { hash, elements });
946
+ while cache.len() > PATH_CACHE_MAX_ENTRIES {
947
+ cache.pop_front();
948
+ }
949
+ }
950
+ }
799
951
  }
800
952
 
953
+ // SAFETY: Reader does not store Ruby VALUE handles. The database source is
954
+ // owned by ReaderSource and is read-only after construction; close atomically
955
+ // swaps the shared source to None. The path cache contains only Rust-owned path
956
+ // elements behind a Mutex. All Ruby object access happens inside method calls
957
+ // while the Ruby VM is active.
801
958
  unsafe impl Send for Reader {}
802
959
 
803
960
  /// Helper function to create a Reader from a ReaderSource
@@ -807,10 +964,152 @@ fn create_reader(source: ReaderSource) -> Reader {
807
964
  Reader {
808
965
  reader: Arc::new(ArcSwapOption::from(Some(source))),
809
966
  closed: Arc::new(AtomicBool::new(false)),
967
+ path_cache: Arc::new(Mutex::new(VecDeque::with_capacity(PATH_CACHE_MAX_ENTRIES))),
810
968
  ip_version,
811
969
  }
812
970
  }
813
971
 
972
+ fn path_array(path: Value, ruby: &magnus::Ruby) -> Result<RArray, Error> {
973
+ RArray::try_convert(path).map_err(|_| {
974
+ Error::new(
975
+ ruby.exception_arg_error(),
976
+ "Path must be an Array of String and Integer elements",
977
+ )
978
+ })
979
+ }
980
+
981
+ fn parse_path_array(path: RArray, ruby: &magnus::Ruby) -> Result<Vec<OwnedPathElement>, Error> {
982
+ let mut elements = Vec::with_capacity(path.len());
983
+ for index in 0..path.len() {
984
+ let item = path.entry::<Value>(index as isize)?;
985
+ if let Ok(key) = RString::try_convert(item) {
986
+ elements.push(OwnedPathElement::Key(key.to_string()?));
987
+ continue;
988
+ }
989
+ if let Ok(index) = isize::try_convert(item) {
990
+ elements.push(signed_index_to_owned_path_element(index));
991
+ continue;
992
+ }
993
+ return Err(Error::new(
994
+ ruby.exception_arg_error(),
995
+ "Path elements must be Strings or Integers",
996
+ ));
997
+ }
998
+
999
+ Ok(elements)
1000
+ }
1001
+
1002
+ #[inline]
1003
+ fn signed_index_to_owned_path_element(index: isize) -> OwnedPathElement {
1004
+ if index >= 0 {
1005
+ OwnedPathElement::Index(index as usize)
1006
+ } else {
1007
+ let index_from_end = index
1008
+ .checked_neg()
1009
+ .and_then(|index| index.checked_sub(1))
1010
+ .map(|index| index as usize)
1011
+ .unwrap_or(usize::MAX);
1012
+ OwnedPathElement::IndexFromEnd(index_from_end)
1013
+ }
1014
+ }
1015
+
1016
+ fn path_cache_hash(path: RArray, ruby: &magnus::Ruby) -> Result<u64, Error> {
1017
+ let mut hasher = FxHasher::default();
1018
+ path.len().hash(&mut hasher);
1019
+
1020
+ for index in 0..path.len() {
1021
+ let item = path.entry::<Value>(index as isize)?;
1022
+ if let Ok(key) = RString::try_convert(item) {
1023
+ 0_u8.hash(&mut hasher);
1024
+ hash_path_key(key, &mut hasher)?;
1025
+ continue;
1026
+ }
1027
+ if let Ok(index) = isize::try_convert(item) {
1028
+ 1_u8.hash(&mut hasher);
1029
+ index.hash(&mut hasher);
1030
+ continue;
1031
+ }
1032
+ return Err(Error::new(
1033
+ ruby.exception_arg_error(),
1034
+ "Path elements must be Strings or Integers",
1035
+ ));
1036
+ }
1037
+
1038
+ Ok(hasher.finish())
1039
+ }
1040
+
1041
+ fn hash_path_key(key: RString, hasher: &mut FxHasher) -> Result<(), Error> {
1042
+ // SAFETY: the borrowed str is used only for immediate hashing and is not
1043
+ // stored across any call that could mutate or free the Ruby string.
1044
+ if let Some(key_str) = unsafe { key.test_as_str() } {
1045
+ key_str.hash(hasher);
1046
+ } else {
1047
+ key.to_string()?.hash(hasher);
1048
+ }
1049
+ Ok(())
1050
+ }
1051
+
1052
+ fn path_matches_cached(path: RArray, cached: &[OwnedPathElement]) -> Result<bool, Error> {
1053
+ if path.len() != cached.len() {
1054
+ return Ok(false);
1055
+ }
1056
+
1057
+ for (index, cached_element) in cached.iter().enumerate() {
1058
+ let item = path.entry::<Value>(index as isize)?;
1059
+ match cached_element {
1060
+ OwnedPathElement::Key(expected) => {
1061
+ let Ok(key) = RString::try_convert(item) else {
1062
+ return Ok(false);
1063
+ };
1064
+ if !path_key_matches(key, expected)? {
1065
+ return Ok(false);
1066
+ }
1067
+ }
1068
+ OwnedPathElement::Index(_) | OwnedPathElement::IndexFromEnd(_) => {
1069
+ let Ok(index) = isize::try_convert(item) else {
1070
+ return Ok(false);
1071
+ };
1072
+ if signed_index_to_owned_path_element(index) != *cached_element {
1073
+ return Ok(false);
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ Ok(true)
1080
+ }
1081
+
1082
+ fn path_key_matches(key: RString, expected: &str) -> Result<bool, Error> {
1083
+ // SAFETY: the borrowed str is used only for immediate comparison and is not
1084
+ // stored across any call that could mutate or free the Ruby string.
1085
+ if let Some(key_str) = unsafe { key.test_as_str() } {
1086
+ Ok(key_str == expected)
1087
+ } else {
1088
+ Ok(key.to_string()? == expected)
1089
+ }
1090
+ }
1091
+
1092
+ fn path_elements_from_owned_path(path: &[OwnedPathElement]) -> Vec<PathElement<'_>> {
1093
+ path.iter()
1094
+ .map(|element| match element {
1095
+ OwnedPathElement::Key(key) => PathElement::Key(key.as_str()),
1096
+ OwnedPathElement::Index(index) => PathElement::Index(*index),
1097
+ OwnedPathElement::IndexFromEnd(index) => PathElement::IndexFromEnd(*index),
1098
+ })
1099
+ .collect()
1100
+ }
1101
+
1102
+ fn ensure_enumerable(value: Value, ruby: &magnus::Ruby, error_message: &str) -> Result<(), Error> {
1103
+ if value.respond_to("each", false)? {
1104
+ Ok(())
1105
+ } else {
1106
+ Err(Error::new(
1107
+ ruby.exception_arg_error(),
1108
+ error_message.to_owned(),
1109
+ ))
1110
+ }
1111
+ }
1112
+
814
1113
  /// Parse IP address from Ruby value (String or IPAddr) - optimized version
815
1114
  #[inline(always)]
816
1115
  fn parse_ip_address_fast(value: Value, ruby: &magnus::Ruby) -> Result<IpAddr, Error> {
@@ -825,12 +1124,7 @@ fn parse_ip_address_fast(value: Value, ruby: &magnus::Ruby) -> Result<IpAddr, Er
825
1124
  )
826
1125
  })?;
827
1126
 
828
- return IpAddr::from_str(ip_str).map_err(|_| {
829
- Error::new(
830
- ruby.exception_arg_error(),
831
- format!("'{}' does not appear to be an IPv4 or IPv6 address", ip_str),
832
- )
833
- });
1127
+ return parse_ip_string(ip_str, ruby);
834
1128
  }
835
1129
 
836
1130
  // Slow path: Try as IPAddr object
@@ -861,15 +1155,7 @@ fn parse_ip_address_fast(value: Value, ruby: &magnus::Ruby) -> Result<IpAddr, Er
861
1155
  }
862
1156
 
863
1157
  if let Ok(ipaddr_obj) = value.funcall::<_, _, String>("to_s", ()) {
864
- return IpAddr::from_str(&ipaddr_obj).map_err(|_| {
865
- Error::new(
866
- ruby.exception_arg_error(),
867
- format!(
868
- "'{}' does not appear to be an IPv4 or IPv6 address",
869
- ipaddr_obj
870
- ),
871
- )
872
- });
1158
+ return parse_ip_string(&ipaddr_obj, ruby);
873
1159
  }
874
1160
 
875
1161
  Err(Error::new(
@@ -878,14 +1164,126 @@ fn parse_ip_address_fast(value: Value, ruby: &magnus::Ruby) -> Result<IpAddr, Er
878
1164
  ))
879
1165
  }
880
1166
 
1167
+ #[inline(always)]
1168
+ fn parse_ip_string(s: &str, ruby: &magnus::Ruby) -> Result<IpAddr, Error> {
1169
+ if let Some(ip) = parse_ipv4_string(s.as_bytes()) {
1170
+ return Ok(IpAddr::V4(ip));
1171
+ }
1172
+
1173
+ IpAddr::from_str(s).map_err(|_| {
1174
+ Error::new(
1175
+ ruby.exception_arg_error(),
1176
+ format!("'{}' does not appear to be an IPv4 or IPv6 address", s),
1177
+ )
1178
+ })
1179
+ }
1180
+
1181
+ #[inline(always)]
1182
+ fn parse_ipv4_string(bytes: &[u8]) -> Option<Ipv4Addr> {
1183
+ let mut octets = [0u8; 4];
1184
+ let mut octet_index = 0;
1185
+ let mut value: u16 = 0;
1186
+ let mut digits = 0;
1187
+
1188
+ for &byte in bytes {
1189
+ if byte == b'.' {
1190
+ if digits == 0 || octet_index == 3 {
1191
+ return None;
1192
+ }
1193
+ octets[octet_index] = value as u8;
1194
+ octet_index += 1;
1195
+ value = 0;
1196
+ digits = 0;
1197
+ continue;
1198
+ }
1199
+
1200
+ if !byte.is_ascii_digit() {
1201
+ return None;
1202
+ }
1203
+ if digits == 1 && value == 0 {
1204
+ return None;
1205
+ }
1206
+
1207
+ digits += 1;
1208
+ if digits > 3 {
1209
+ return None;
1210
+ }
1211
+ value = value * 10 + u16::from(byte - b'0');
1212
+ if value > u16::from(u8::MAX) {
1213
+ return None;
1214
+ }
1215
+ }
1216
+
1217
+ if octet_index != 3 || digits == 0 {
1218
+ return None;
1219
+ }
1220
+ octets[octet_index] = value as u8;
1221
+
1222
+ Some(Ipv4Addr::from(octets))
1223
+ }
1224
+
1225
+ #[inline]
1226
+ fn lookup_result_to_value(
1227
+ ruby: &magnus::Ruby,
1228
+ result: Result<Option<RubyDecodedValue>, MaxMindDbError>,
1229
+ error_context: &str,
1230
+ ) -> Result<Value, Error> {
1231
+ match result {
1232
+ Ok(Some(data)) => Ok(data.into_value()),
1233
+ Ok(None) => Ok(ruby.qnil().as_value()),
1234
+ Err(err) => Err(lookup_error(ruby, err, error_context)),
1235
+ }
1236
+ }
1237
+
1238
+ #[inline]
1239
+ fn lookup_prefix_result_to_array(
1240
+ ruby: &magnus::Ruby,
1241
+ result: Result<(Option<RubyDecodedValue>, usize), MaxMindDbError>,
1242
+ error_context: &str,
1243
+ ) -> Result<RArray, Error> {
1244
+ match result {
1245
+ Ok((data, prefix)) => {
1246
+ let arr = ruby.ary_new();
1247
+ arr.push(data.map_or_else(|| ruby.qnil().as_value(), RubyDecodedValue::into_value))?;
1248
+ arr.push(prefix.into_value_with(ruby))?;
1249
+ Ok(arr)
1250
+ }
1251
+ Err(err) => Err(lookup_error(ruby, err, error_context)),
1252
+ }
1253
+ }
1254
+
1255
+ #[inline]
1256
+ fn lookup_error(ruby: &magnus::Ruby, err: MaxMindDbError, context: &str) -> Error {
1257
+ match err {
1258
+ MaxMindDbError::InvalidDatabase { .. }
1259
+ | MaxMindDbError::Decoding { .. }
1260
+ | MaxMindDbError::Io(_) => invalid_database_exception(ERR_BAD_DATA),
1261
+ other => Error::new(
1262
+ ruby.exception_runtime_error(),
1263
+ format!("{}: {}", context, other),
1264
+ ),
1265
+ }
1266
+ }
1267
+
881
1268
  /// Generate error message for IPv6 in IPv4-only database
882
1269
  fn ipv6_in_ipv4_error(ip: &IpAddr) -> String {
883
1270
  format!(
884
- "Error looking up {}. You attempted to look up an IPv6 address in an IPv4-only database",
1271
+ "Error looking up {}. You attempted to look up an IPv6 address in an IPv4-only database.",
885
1272
  ip
886
1273
  )
887
1274
  }
888
1275
 
1276
+ fn database_path(database: Value) -> Result<String, Error> {
1277
+ RString::try_convert(database)?.to_string()
1278
+ }
1279
+
1280
+ fn database_buffer(database: Value) -> Result<Vec<u8>, Error> {
1281
+ let string = RString::try_convert(database)?;
1282
+ // SAFETY: the slice is copied into an owned Vec before Ruby can mutate or
1283
+ // free the string, and the reader only ever sees the owned bytes.
1284
+ Ok(unsafe { string.as_slice() }.to_vec())
1285
+ }
1286
+
889
1287
  /// Open a MaxMind DB using memory-mapped I/O (MODE_MMAP)
890
1288
  fn open_database_mmap(path: &str) -> Result<Reader, Error> {
891
1289
  let ruby = magnus::Ruby::get().expect("Ruby VM should be available in Ruby context");
@@ -898,14 +1296,10 @@ fn open_database_mmap(path: &str) -> Result<Reader, Error> {
898
1296
  )
899
1297
  })?;
900
1298
  let reader = MaxMindReader::from_source(mmap).map_err(|_| {
901
- Error::new(
902
- ExceptionClass::from_value(invalid_database_error().as_value())
903
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
904
- format!(
905
- "Error opening database file ({}). Is this a valid MaxMind DB file?",
906
- path
907
- ),
908
- )
1299
+ invalid_database_exception(&format!(
1300
+ "Error opening database file ({}). Is this a valid MaxMind DB file?",
1301
+ path
1302
+ ))
909
1303
  })?;
910
1304
 
911
1305
  Ok(create_reader(ReaderSource::Mmap(reader)))
@@ -924,16 +1318,25 @@ fn open_database_memory(path: &str) -> Result<Reader, Error> {
924
1318
  )
925
1319
  })?;
926
1320
 
927
- let reader = MaxMindReader::from_source(buffer).map_err(|_| {
928
- Error::new(
929
- ExceptionClass::from_value(invalid_database_error().as_value())
930
- .expect("InvalidDatabaseError should convert to ExceptionClass"),
931
- format!(
932
- "Error opening database file ({}). Is this a valid MaxMind DB file?",
933
- path
934
- ),
935
- )
936
- })?;
1321
+ reader_from_buffer(
1322
+ buffer,
1323
+ format!(
1324
+ "Error opening database file ({}). Is this a valid MaxMind DB file?",
1325
+ path
1326
+ ),
1327
+ )
1328
+ }
1329
+
1330
+ fn open_database_buffer(buffer: Vec<u8>) -> Result<Reader, Error> {
1331
+ reader_from_buffer(
1332
+ buffer,
1333
+ "Error opening database from buffer. Is this a valid MaxMind DB file?".to_owned(),
1334
+ )
1335
+ }
1336
+
1337
+ fn reader_from_buffer(buffer: Vec<u8>, invalid_message: String) -> Result<Reader, Error> {
1338
+ let reader = MaxMindReader::from_source(buffer)
1339
+ .map_err(|_| invalid_database_exception(invalid_message.as_str()))?;
937
1340
 
938
1341
  Ok(create_reader(ReaderSource::Memory(reader)))
939
1342
  }
@@ -971,6 +1374,14 @@ fn invalid_database_error() -> RClass {
971
1374
  .expect("InvalidDatabaseError class should exist")
972
1375
  }
973
1376
 
1377
+ fn invalid_database_exception(message: &str) -> Error {
1378
+ Error::new(
1379
+ ExceptionClass::from_value(invalid_database_error().as_value())
1380
+ .expect("InvalidDatabaseError should convert to ExceptionClass"),
1381
+ message.to_owned(),
1382
+ )
1383
+ }
1384
+
974
1385
  fn rust_module(ruby: &magnus::Ruby) -> RModule {
975
1386
  let maxmind = ruby
976
1387
  .class_object()
@@ -1055,13 +1466,17 @@ fn init(ruby: &magnus::Ruby) -> Result<(), Error> {
1055
1466
  let reader_class = rust.define_class("Reader", ruby.class_object())?;
1056
1467
  reader_class.define_singleton_method("new", magnus::function!(Reader::new, -1))?;
1057
1468
  reader_class.define_method("get", magnus::method!(Reader::get, 1))?;
1469
+ reader_class.define_method("get_path", magnus::method!(Reader::get_path, 2))?;
1058
1470
  reader_class.define_method(
1059
1471
  "get_with_prefix_length",
1060
1472
  magnus::method!(Reader::get_with_prefix_length, 1),
1061
1473
  )?;
1474
+ reader_class.define_method("get_many", magnus::method!(Reader::get_many, 1))?;
1475
+ reader_class.define_method("get_many_path", magnus::method!(Reader::get_many_path, 2))?;
1062
1476
  reader_class.define_method("metadata", magnus::method!(Reader::metadata, 0))?;
1063
1477
  reader_class.define_method("close", magnus::method!(Reader::close, 0))?;
1064
1478
  reader_class.define_method("closed", magnus::method!(Reader::closed, 0))?;
1479
+ reader_class.define_method("inspect", magnus::method!(Reader::inspect, 0))?;
1065
1480
  reader_class.define_method("each", magnus::method!(Reader::each, -1))?;
1066
1481
 
1067
1482
  // Include Enumerable module
@@ -1096,8 +1511,49 @@ fn init(ruby: &magnus::Ruby) -> Result<(), Error> {
1096
1511
 
1097
1512
  // Define MODE constants
1098
1513
  rust.const_set("MODE_AUTO", ruby.to_symbol("MODE_AUTO"))?;
1514
+ rust.const_set("MODE_FILE", ruby.to_symbol("MODE_FILE"))?;
1099
1515
  rust.const_set("MODE_MEMORY", ruby.to_symbol("MODE_MEMORY"))?;
1100
1516
  rust.const_set("MODE_MMAP", ruby.to_symbol("MODE_MMAP"))?;
1517
+ rust.const_set(
1518
+ "MODE_PARAM_IS_BUFFER",
1519
+ ruby.to_symbol("MODE_PARAM_IS_BUFFER"),
1520
+ )?;
1101
1521
 
1102
1522
  Ok(())
1103
1523
  }
1524
+
1525
+ #[cfg(test)]
1526
+ mod tests {
1527
+ use super::parse_ipv4_string;
1528
+ use std::net::Ipv4Addr;
1529
+
1530
+ #[test]
1531
+ fn parses_strict_ipv4_strings() {
1532
+ assert_eq!(
1533
+ parse_ipv4_string(b"0.1.2.255"),
1534
+ Some(Ipv4Addr::new(0, 1, 2, 255))
1535
+ );
1536
+ assert_eq!(
1537
+ parse_ipv4_string(b"192.0.2.1"),
1538
+ Some(Ipv4Addr::new(192, 0, 2, 1))
1539
+ );
1540
+ }
1541
+
1542
+ #[test]
1543
+ fn rejects_ipv4_strings_that_std_parser_rejects() {
1544
+ for value in [
1545
+ b"01.2.3.4".as_slice(),
1546
+ b"1.02.3.4".as_slice(),
1547
+ b"1.2.3.04".as_slice(),
1548
+ b"1.2.3".as_slice(),
1549
+ b"1.2.3.4.5".as_slice(),
1550
+ b"1..2.3".as_slice(),
1551
+ b"256.1.1.1".as_slice(),
1552
+ b"1.2.3.4 ".as_slice(),
1553
+ b" 1.2.3.4".as_slice(),
1554
+ b"2001:db8::1".as_slice(),
1555
+ ] {
1556
+ assert_eq!(parse_ipv4_string(value), None);
1557
+ }
1558
+ }
1559
+ }
@@ -66,7 +66,7 @@ module MaxMind
66
66
  # - Reader class
67
67
  # - Metadata class
68
68
  # - InvalidDatabaseError exception
69
- # - MODE_AUTO, MODE_MEMORY, MODE_MMAP constants
69
+ # - MODE_AUTO, MODE_FILE, MODE_MEMORY, MODE_MMAP, MODE_PARAM_IS_BUFFER constants
70
70
  end
71
71
  else
72
72
  # Official gem not loaded - define DB as a module
@@ -128,7 +128,7 @@ module MaxMind
128
128
  # - Reader class
129
129
  # - Metadata class
130
130
  # - InvalidDatabaseError exception
131
- # - MODE_AUTO, MODE_MEMORY, MODE_MMAP constants
131
+ # - MODE_AUTO, MODE_FILE, MODE_MEMORY, MODE_MMAP, MODE_PARAM_IS_BUFFER constants
132
132
  end
133
133
  end
134
134
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maxmind-db-rust
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory Oschwald
@@ -191,7 +191,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
191
191
  - !ruby/object:Gem::Version
192
192
  version: '0'
193
193
  requirements: []
194
- rubygems_version: 4.0.6
194
+ rubygems_version: 4.0.10
195
195
  specification_version: 4
196
196
  summary: Unofficial high-performance Rust-based MaxMind DB reader for Ruby
197
197
  test_files: []