inet 0.1.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5372b62775ce7f9a484b88d77115a10e6ffe8ede9f311c683bbb160d6b13b119
4
- data.tar.gz: e70a880902a29c15d316e51796818a4f8bd223cc4ca9af5a43aec9e393b69c27
3
+ metadata.gz: d1b022216eda75fb5d2cf23f4b69b0e225069b7ec53b16400709fd91b7a804b2
4
+ data.tar.gz: c3d2eb967e3cb4a3abef18ba46e99c3fdbb69d1cf8c8f4dab534f3a3e4b5d25a
5
5
  SHA512:
6
- metadata.gz: 452788add21b2bcc8993f97e3c5abed49e0f7d82aed975552b268f1bd82bd704768df2efa621d359edb4a619964db0ab5bb292e29037535359f62862b97903ac
7
- data.tar.gz: b37bc47a8ad6e9deb3ce78205e7f8a7f426694c697078da9c2cff27d05b8bfd7faa130866f66cb1d05744917302c5e60442367e501fa3c81c1252d4d522449dc
6
+ metadata.gz: 54fb104ff7fb73fe2291fe583fa1ad11b14bcff967e4d3b591e3993aed508685958bd6b69932065b0e9ec7607650f7f7f3b76b4e831cfc29473afa781f01af7c
7
+ data.tar.gz: 401fa85bb5d0461dd6646facd4313e539b3a722e7a95aef16054e6e29d05f7306d8e8b13cbbe8a9fce3cbddccb324f70e6643f93e78cdcdf2a73825273bf7f08
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.0
4
+
5
+ 1. **域名与 DNS**:从 algosec-rb 迁入 `Inet::DomainName` 和 `Inet::DnsResolver`,提供完整域名校验、左侧通配匹配、A/CNAME 解析与 TTL 缓存。
6
+ 2. **通用上下文**:解析器以 `context:` 隔离调用方缓存,默认使用 `Time.now`,不依赖 ActiveSupport;声明所需标准库 gem 依赖。
7
+
8
+ ## 0.2.0
9
+
10
+ 1. **地址与服务运算**:从 algosec-rb 迁入 IPv4、IPv6 地址规范化、范围解析、CIDR 拆分、端口及协议服务编码,公共入口为 `Inet::IPAddr`、`Inet::IPv6`。
11
+ 2. **区间集合**:提供不可变 `Inet::Set`,支持合并、交集、差集、包含、重叠、关系判断和 Marshal 持久化;无需 ActiveSupport 或其他运行时依赖。
12
+ 3. **验证**:补充边界、非法输入、集合运算对照测试和真实 gem 隔离安装验证。
13
+
3
14
  ## 0.1.1
4
15
 
5
16
  1. **自动发布**:接通 RubyGems Trusted Publishing,由 GitHub Actions 发布通过全部检查的同一份 gem,并提供对应的 GitHub Release 和 SHA256 校验文件。
data/LICENSE CHANGED
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 inet contributors
4
+ Copyright (c) 2026 Gatework contributors
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![Release](https://github.com/gatework/inet/actions/workflows/release.yml/badge.svg)](https://github.com/gatework/inet/actions/workflows/release.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/inet.svg)](https://rubygems.org/gems/inet)
5
5
 
6
- `inet` 的 Ruby gem 基础骨架。当前仅提供 `Inet` 命名空间和版本信息,尚未实现网络功能。
6
+ `inet` 提供 IPv4、IPv6 地址与服务范围运算,域名匹配、带 TTL 缓存的 DNS 解析,以及不可变整数区间集合。加载时不发起网络请求或启动后台任务。
7
7
 
8
- 要求 Ruby 3.4 或更高版本,无运行时依赖,采用 MIT 许可证。
8
+ 要求 Ruby 3.4 或更高版本,只依赖标准库 gem,采用 MIT 许可证。
9
9
 
10
10
  ## 安装与使用
11
11
 
@@ -16,15 +16,66 @@ gem install inet
16
16
  也可以在 Gemfile 中添加:
17
17
 
18
18
  ```ruby
19
- gem "inet", "~> 0.1"
19
+ gem "inet", "~> 0.3"
20
20
  ```
21
21
 
22
22
  ```ruby
23
23
  require "inet"
24
24
 
25
- puts Inet::VERSION # => "0.1.1"
25
+ Inet::IPAddr.host_cidr("192.0.2.9/24") # => "192.0.2.9/24"
26
+ Inet::IPAddr.network_cidr("192.0.2.9/24") # => "192.0.2.0/24"
27
+ Inet::IPAddr.cidrs(Inet::IPAddr.parse("192.0.2.0/24")) # => [["192.0.2.0", 24]]
28
+ Inet::IPv6.canonical("2001:0db8::9/64") # => "2001:db8::/64"
29
+ Inet::IPv6.canonical("2001:db8::9/64", host: true) # => "2001:db8::9/64"
30
+
31
+ allowed = Inet::Set.new(1..10) - Inet::Set.new(3..7)
32
+ allowed.ranges # => [1..2, 8..10]
33
+ allowed.cardinality # => 5
34
+ ```
35
+
36
+ ## 接口约定
37
+
38
+ - `Inet::IPAddr` 继承标准库 `IPAddr`,只接受 IPv4 字符串或整数;设备格式中的前导零按十进制处理,`any` 表示 `0.0.0.0`,全部 IPv4 地址使用 `any/0`。
39
+ - `host_cidr` 保留主机位,`network_cidr` 归一化网络地址;可传第二个参数指定前缀长度或连续网络掩码。`host` 返回主机文本,`to_i` / `from_i` 转换整数,`prefix_length` / `netmask` / `wildcard_mask` 转换掩码。
40
+ - `parse` 接受主机、CIDR、点分掩码或 `起点-终点`,返回 `Inet::Set`。IPv4 的范围端点自动排序;`between` 接受两个端点,`subnet` / `network` 接受地址及掩码。`cidrs` 接受合法 IPv4 整数集合并返回 `[地址, 前缀]` 数组;`format_range` 接受合法闭区间并返回单个 CIDR 或端点表达式。
41
+ - `ports` 接受端口整数、区间、单个端口文本或以连字符/空格分隔的范围,限制为 `0..65535`。`protocol_number` 接受名称、符号或 `0..255` 的编号;`icmp?` 判断 ICMP/ICMPv6。`service("tcp/80-90")` 返回服务整数集合,高八位为协议号,低十六位为端口;省略端口使用零,`any` 或协议 `0` 表示整个 24 位服务空间。
42
+ - `Inet::IPv6.address` 返回标准库 `IPAddr`;`canonical` 规范化主机或 CIDR,`parse` 返回整数集合,`normalize` 输出规范文本。范围必须按从小到大排列且不带前缀;拒绝带区域标识 `%` 的地址。`from_i` 转换 128 位非负整数,`ranges` 把合法 IPv6 整数集合转换为地址文本端点对。
43
+ - `Inet::Set.new(*ranges)` 接受非负整数区间,自动合并重叠及相邻区间;半开区间转换为闭区间,拒绝空或逆序区间。`each` 遍历区间,`size` 是区间段数,`cardinality` 是整数元素总数。空集合的 `min` / `max` 返回 `nil`。
44
+ - 集合支持 `|`、`&`、`-`、`include?(整数)`、`cover?(整数或集合)`、`subset?`、`overlap?`、`relation`。关系返回 `:equal`、`:contains`、`:within` 或 `:other`。集合和值数组冻结,`to_a` 返回数组副本;相等性、哈希和 Marshal 恢复保持值语义。
45
+
46
+ 非法地址、超范围值和不支持的类型抛出 `ArgumentError`(包括标准库 `IPAddr` 的对应子类)。集合自身不区分 IPv4、IPv6、端口或服务,调用方须保持同一数值空间,避免跨类型混算。
47
+ 可分别 `require "inet/ipaddr"`、`require "inet/ipv6"` 或 `require "inet/set"`。
48
+
49
+ 这三个模块迁自 Gatework 的 algosec-rb,保留其公共方法与运算行为;最低 Ruby 版本自带的 `Range#overlap?` 替代原 ActiveSupport 加载。源代码采用 MIT 许可证,版权声明见 [LICENSE](LICENSE)。
50
+
51
+ ## 域名与 DNS
52
+
53
+ ```ruby
54
+ Inet::DomainName.normalize("WWW.Example.test.") # => "www.example.test"
55
+ Inet::DomainName.exact?("example.test") # => true
56
+ Inet::DomainName.matches?("*.example.test", "www.example.test") # => true
57
+
58
+ resolver = Inet::DnsResolver.new(context: { "device_id" => 42 }, servers: ["192.0.2.53"])
59
+ answer = resolver.resolve("www.example.test") # 此时才查询 DNS
60
+ answer.fetch("status") # => "resolved"、"unresolved" 或 "unsupported"
61
+ answer.fetch("addresses") # => IPv4 地址数组
26
62
  ```
27
63
 
64
+ 域名只接受至少两个 ASCII 标签的完整名称,单个尾点会被移除;不支持 IDNA 转码和搜索域。
65
+ 通配仅支持 `*.example.test`,匹配一层或多层子域,不匹配根名称 `example.test`。
66
+ `require "inet/domain_name"` 和 `require "inet/dns_resolver"` 也可独立加载。
67
+
68
+ 解析器只查询 A 记录,跟随最多 8 个 CNAME,使用整条链中最短的 TTL;每次等待为 1 秒。
69
+ `servers:` 最多指定三个 DNS 服务器,省略时使用系统 DNS。`context:` 为字符串键的 JSON 对象,
70
+ 由解析器复制并冻结,供调用方隔离租户或设备缓存。返回的 `source` 保留上下文,追加
71
+ `source`(系统 DNS 为 `system`,显式 DNS 为 `device`)和 `servers`,这两个键由解析器管理。
72
+
73
+ 返回值及嵌套内容不可变,包含 `status`、`addresses`、`resolved_at`、`expires_at`、`source`、`error`。
74
+ 失败或没有 A 记录返回 `unresolved`,不支持的域名返回 `unsupported`,都缓存 15 秒;不会把失败当成成功的空集合。
75
+ 可选 `store:` 接收实现 `read(key)` 与 `write(key, value, expires_in:)` 的缓存,实例内也按 TTL 复用结果。
76
+ `clock:` 默认 `Time.now`;测试可注入 `transport:`(接收域名及显式服务器数组,返回 `[地址数组, TTL秒数]`)
77
+ 或 `dns_class:`。加载 gem 不发起 DNS 查询。
78
+
28
79
  ## 开发
29
80
 
30
81
  ```sh
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require "digest"
5
+ require "json"
6
+ require "time"
7
+ require_relative "ipaddr"
8
+ require_relative "domain_name"
9
+
10
+ module Inet
11
+ # 按调用方上下文和 DNS 视角缓存有期限的证据;不把 DNS 失败解释为空地址。
12
+ class DnsResolver
13
+ FAILURE_TTL = 15
14
+ MAX_CNAME_DEPTH = 8
15
+ attr_reader :context
16
+
17
+ def initialize(context: {}, servers: nil, store: nil, clock: -> { Time.now }, transport: nil, dns_class: Resolv::DNS)
18
+ servers = Array(servers)
19
+ raise ArgumentError, "DNS servers must contain at most three IP addresses" if servers.size > 3
20
+
21
+ servers.each { |server| ::IPAddr.new(server) }
22
+ @servers = servers.map { |server| server.to_s.dup.freeze }.freeze
23
+ @store = store
24
+ @clock = clock
25
+ @transport = transport
26
+ @dns_class = dns_class
27
+ effective = if @servers.empty?
28
+ Resolv::DNS::Config.default_config_hash.fetch(:nameserver).map do |server|
29
+ server.dup.freeze
30
+ end.freeze
31
+ else
32
+ @servers
33
+ end
34
+ @context = immutable_answer(context.merge("source" => @servers.empty? ? "system" : "device",
35
+ "servers" => effective))
36
+ @scope = Digest::SHA256.hexdigest(JSON.generate(@context))
37
+ @answers = {}
38
+ end
39
+
40
+ # 同次解析只查一次;跨次操作复用 TTL 内缓存,失败缓存不被视作成功。
41
+ def resolve(domain)
42
+ name = DomainName.normalize(domain)
43
+ key = ["inet", "dns-v1", @scope, name]
44
+ now = @clock.call
45
+ cached = @answers[name] || @store&.read(key)
46
+ return @answers[name] ||= immutable_answer(cached) if cached && Time.iso8601(cached.fetch("expires_at")) > now
47
+
48
+ answer = lookup(name, now)
49
+ @store&.write(key, answer, expires_in: [Time.iso8601(answer.fetch("expires_at")) - now, 0].max)
50
+ @answers[name] = answer
51
+ end
52
+
53
+ private
54
+
55
+ # 缓存编码器不保留冻结状态;复制恢复每层 JSON 值,避免调用方改写 DNS 证据。
56
+ def immutable_answer(value)
57
+ case value
58
+ when Hash then value.to_h { |key, item| [key, immutable_answer(item)] }.freeze
59
+ when Array then value.map { |item| immutable_answer(item) }.freeze
60
+ else value.dup.freeze
61
+ end
62
+ end
63
+
64
+ # 仅接受完整域名;通配和单标签主机名不借助本机搜索域猜测。
65
+ def lookup(name, now)
66
+ return record("unsupported", [], FAILURE_TTL, now, "Unsupported domain expression") unless DomainName.exact?(name)
67
+
68
+ addresses, ttl = @transport ? @transport.call(name, @servers) : query(name)
69
+ addresses = addresses.map { |ip| Inet::IPAddr.from_i(Inet::IPAddr.to_i(ip)) }.uniq.sort
70
+ return record("unresolved", [], FAILURE_TTL, now, "No A records") if addresses.empty?
71
+
72
+ record("resolved", addresses, Integer(ttl), now)
73
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout, IOError, SystemCallError, ArgumentError => error
74
+ record("unresolved", [], FAILURE_TTL, now, error.message)
75
+ end
76
+
77
+ # CNAME 与 A 的 TTL 共同决定有效期,限制跳数和每次等待,防止循环和长时间阻塞。
78
+ def query(name)
79
+ servers = context.fetch("servers")
80
+ @dns_class.open(nameserver: servers, search: [], ndots: 1, raise_timeout_errors: true) do |dns|
81
+ dns.timeouts = 1
82
+ visited = []
83
+ ttls = []
84
+ MAX_CNAME_DEPTH.times do
85
+ raise Resolv::ResolvError, "CNAME cycle" if visited.include?(name)
86
+
87
+ visited << name
88
+ aliases = dns.getresources("#{name}.", Resolv::DNS::Resource::IN::CNAME)
89
+ if aliases.empty?
90
+ records = dns.getresources("#{name}.", Resolv::DNS::Resource::IN::A)
91
+ ttls.concat(records.map(&:ttl))
92
+ return [records.map { |rr| rr.address.to_s }, ttls.min || 0]
93
+ end
94
+ ttls.concat(aliases.map(&:ttl))
95
+ name = aliases.first.name.to_s.downcase.delete_suffix(".")
96
+ end
97
+ raise Resolv::ResolvError, "CNAME chain exceeds #{MAX_CNAME_DEPTH}"
98
+ end
99
+ end
100
+
101
+ # 使用普通 JSON 值保存证据,配置缓存不持有网络连接或解析器对象。
102
+ def record(status, addresses, ttl, now, error = nil)
103
+ { "status" => status, "addresses" => addresses.map(&:freeze).freeze, "resolved_at" => now.iso8601(6).freeze,
104
+ "expires_at" => (now + [ttl, 0].max).iso8601(6).freeze, "source" => context,
105
+ "error" => error&.dup&.freeze }.freeze
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inet
4
+ # 域名表达式只支持完整名称或最左侧标签通配,不使用子串匹配。
5
+ module DomainName
6
+ module_function
7
+
8
+ def normalize(value)
9
+ name = value.to_s.downcase
10
+ # 一个尾点表示根域;多个尾点是空标签,跨层重复归一化也不能把它变成合法名称。
11
+ name.end_with?("..") ? name : name.delete_suffix(".")
12
+ end
13
+
14
+ def exact?(value)
15
+ name = normalize(value)
16
+ name.bytesize <= 253 && name.include?(".") &&
17
+ name.split(".", -1).all? { |label| /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/.match?(label) }
18
+ end
19
+
20
+ def wildcard?(value)
21
+ name = normalize(value)
22
+ name.start_with?("*.") && exact?(name.delete_prefix("*."))
23
+ end
24
+
25
+ def matches?(expression, domain)
26
+ pattern, name = [expression, domain].map { |value| normalize(value) }
27
+ return false unless exact?(name)
28
+
29
+ wildcard?(pattern) ? name.end_with?(pattern.delete_prefix("*")) : exact?(pattern) && pattern == name
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "set"
5
+
6
+ module Inet
7
+ # 在标准 IPAddr 能力上补充防火墙所需的 IPv4 和服务范围运算。
8
+ class IPAddr < ::IPAddr
9
+ IPV4_MAX = ::IPAddr::IN4MASK
10
+ PORT_MAX = (1 << 16) - 1
11
+ SERVICE_MAX = (1 << 24) - 1
12
+ # 服务编码中反复使用的协议号集中命名,避免上层散落裸数字。
13
+ ANY_PROTOCOL = 0
14
+ ICMP = 1
15
+ ICMPV6 = 58
16
+ TCP = 6
17
+ UDP = 17
18
+ PROTOCOL_NUMBERS = { "tcp" => TCP, "udp" => UDP, "icmp" => ICMP, "icmpv6" => ICMPV6,
19
+ "ms-rpc-tcp" => TCP, "ms-rpc-udp" => UDP }.freeze
20
+
21
+ # 规范化 IPv4 输入,并拒绝 IPv6、非法前缀及非十进制设备写法。
22
+ def initialize(address = "0.0.0.0", family = Socket::AF_INET)
23
+ unless [Socket::AF_INET, Socket::AF_UNSPEC].include?(family)
24
+ raise ArgumentError, "Firewall IP helpers require IPv4"
25
+ end
26
+
27
+ if address.is_a?(String)
28
+ address = address.sub(%r{\Aany(?=/|\z)}i, "0.0.0.0")
29
+ unless %r{\A[0-9]+(?:\.[0-9]+){3}(?:/(?:[0-9]+|[0-9]+(?:\.[0-9]+){3}))?\z}.match?(address)
30
+ raise ArgumentError, "Invalid IPv4 expression: #{address.inspect}"
31
+ end
32
+
33
+ # 设备格式把带前导零的八位组和前缀都按十进制解释。
34
+ address = address.gsub(/\b0+(?=[0-9])/, "")
35
+ elsif !address.is_a?(Integer)
36
+ raise ArgumentError, "IPv4 input must be a string or integer"
37
+ end
38
+ super(address, Socket::AF_INET)
39
+ end
40
+
41
+ # 把地址转换成包含首尾整数的标准集合,供范围运算复用。
42
+ def to_set
43
+ raise ArgumentError, "Firewall IP helpers require IPv4" unless ipv4?
44
+
45
+ range = to_range
46
+ Set.new(range.begin.to_i..range.end.to_i)
47
+ end
48
+
49
+ class << self
50
+ # 接口地址保留主机位;不能直接用 IPAddr.new("host/prefix").to_s。
51
+ def host_cidr(expression, mask = nil)
52
+ host, prefix = string(expression, label: "Interface address").split("/", 2)
53
+ "#{from_i(to_i(host))}/#{prefix_length(mask || prefix || 32)}"
54
+ end
55
+
56
+ # 路由目的统一归一化为网络地址和前缀。
57
+ def network_cidr(expression, mask = nil)
58
+ address = new(host_cidr(expression, mask))
59
+ "#{address}/#{address.prefix}"
60
+ end
61
+
62
+ # 取接口 CIDR 中的主机地址,供 NAT 出口使用。
63
+ def host(expression) = host_cidr(expression).split("/", 2).first
64
+
65
+ # 把不带前缀的 IPv4 文本转换为整数。
66
+ def to_i(address)
67
+ unless address.is_a?(String) && !address.include?("/")
68
+ raise ArgumentError, "Invalid IPv4 address: #{address.inspect}"
69
+ end
70
+
71
+ new(address).to_i
72
+ end
73
+
74
+ # 把 IPv4 整数转换为点分十进制文本。
75
+ def from_i(number)
76
+ value = integer(number, max: IPV4_MAX, label: "IPv4 integer")
77
+ new(value).to_s
78
+ end
79
+
80
+ # 把前缀长度或掩码转换为规范前缀长度。
81
+ def prefix_length(mask)
82
+ mask = integer(mask, max: 32, label: "Prefix length") unless mask.is_a?(String)
83
+ new("0.0.0.0/#{mask}").prefix
84
+ end
85
+
86
+ # 根据前缀长度生成点分十进制网络掩码。
87
+ def netmask(mask) = new(IPV4_MAX).mask(prefix_length(mask)).to_s
88
+
89
+ # 把通配符掩码转换为网络掩码。
90
+ def wildcard_mask(wildcard)
91
+ unless wildcard.is_a?(String) && /\A[0-9]+(?:\.[0-9]+){3}\z/.match?(wildcard)
92
+ raise ArgumentError, "Invalid IPv4 wildcard"
93
+ end
94
+
95
+ (~new(wildcard)).to_s
96
+ end
97
+
98
+ # 根据地址和掩码计算覆盖的整数地址集合。
99
+ def subnet(address, mask = 32)
100
+ new(to_i(address)).mask(prefix_length(mask.nil? ? 32 : mask)).to_set
101
+ end
102
+
103
+ # 返回子网的网络地址文本。
104
+ def network(address, mask = 32) = from_i(subnet(address, mask).min)
105
+
106
+ # 解析主机、CIDR 或网络掩码表达式,也支持两个 IPv4 端点。
107
+ def parse(expression)
108
+ text = string(expression, label: "IPv4 expression")
109
+ parts = text.split("-", -1)
110
+ return between(*parts) if parts.size == 2
111
+ raise ArgumentError, "Invalid IPv4 expression: #{text}" unless parts.size == 1
112
+
113
+ new(text).to_set
114
+ end
115
+
116
+ # 用两个端点创建有序的闭区间。
117
+ def between(first, last)
118
+ low, high = [to_i(first), to_i(last)].minmax
119
+ Set.new(low..high)
120
+ end
121
+
122
+ # 将地址范围精确拆成 CIDR,供持久化与命令生成共用。
123
+ def cidrs(set)
124
+ set.flat_map do |range|
125
+ cursor = range.begin
126
+ last = range.end
127
+ blocks = []
128
+ while cursor <= last
129
+ size = 1
130
+ size *= 2 while (cursor % (size * 2)).zero? && cursor + (size * 2) - 1 <= last
131
+ blocks << [IPAddr.from_i(cursor), 32 - (size.bit_length - 1)]
132
+ cursor += size
133
+ end
134
+ blocks
135
+ end
136
+ end
137
+
138
+ # 把整数区间压缩成最短的单个网段或端点表达式。
139
+ def format_range(range)
140
+ unless range.is_a?(Range) && !range.exclude_end? && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
141
+ raise ArgumentError, "IPv4 notation requires a closed integer range"
142
+ end
143
+
144
+ integer(range.begin, max: IPV4_MAX, label: "IPv4 lower bound")
145
+ integer(range.end, max: IPV4_MAX, label: "IPv4 upper bound")
146
+ raise ArgumentError, "IPv4 range is reversed" if range.begin > range.end
147
+
148
+ size = range.end - range.begin + 1
149
+ first = from_i(range.begin)
150
+ if size.nobits?(size - 1) && (range.begin % size).zero?
151
+ "#{first}/#{32 - (size.bit_length - 1)}"
152
+ else
153
+ "#{first}-#{from_i(range.end)}"
154
+ end
155
+ end
156
+
157
+ # 解析单个端口或端口范围,并限制在 16 位端口空间内。
158
+ def ports(expression)
159
+ if expression.is_a?(Range)
160
+ result = Set.new(expression)
161
+ raise ArgumentError, "Port must be in 0..#{PORT_MAX}" if result.max > PORT_MAX
162
+
163
+ return result
164
+ end
165
+ text = expression.is_a?(Integer) ? expression.to_s : string(expression, label: "Port")
166
+ match = /\A\s*([0-9]+)(?:(?:\s*-\s*|\s+)([0-9]+))?\s*\z/.match(text)
167
+ raise ArgumentError, "Invalid port expression: #{text}" unless match
168
+
169
+ first, last = [match[1], match[2] || match[1]].map do |endpoint|
170
+ integer(endpoint, max: PORT_MAX, label: "Port")
171
+ end.minmax
172
+ Set.new(first..last)
173
+ end
174
+
175
+ # 把协议名称或编号转换成服务编码使用的协议编号。
176
+ def protocol_number(protocol)
177
+ protocol = protocol.to_s if protocol.is_a?(Integer) || protocol.is_a?(Symbol)
178
+ name = string(protocol, label: "Protocol").downcase
179
+ return ANY_PROTOCOL if name == "any"
180
+
181
+ PROTOCOL_NUMBERS.fetch(name) { integer(name, max: 255, label: "Protocol number") }
182
+ end
183
+
184
+ # 两种 ICMP 都以消息类型和代码匹配,不把类型限制当作普通目的端口。
185
+ def icmp?(protocol) = [ICMP, ICMPV6].include?(protocol_number(protocol))
186
+
187
+ # 服务编码的高八位保存协议,低十六位保存目标端口;省略端口时使用零。
188
+ def service(expression)
189
+ text = string(expression, label: "Service expression")
190
+ protocol, port, *unexpected_parts = text.split("/", -1)
191
+ raise ArgumentError, "Invalid service expression: #{text}" unless protocol && unexpected_parts.empty?
192
+
193
+ ranges = ports(port || 0)
194
+ return Set.new(0..SERVICE_MAX) if protocol.casecmp?(ANY_PROTOCOL.to_s) || protocol.casecmp?("any")
195
+
196
+ base = protocol_number(protocol) << 16
197
+ Set.new(*ranges.map { |range| (base + range.begin)..(base + range.end) })
198
+ end
199
+
200
+ private
201
+
202
+ # 校验并转换整数输入,同时限制其数值范围。
203
+ def integer(value, max:, label:)
204
+ unless value.is_a?(Integer) || (value.is_a?(String) && /\A[0-9]+\z/.match?(value))
205
+ raise ArgumentError, "#{label} must be an integer in 0..#{max}"
206
+ end
207
+
208
+ number = value.is_a?(Integer) ? value : Integer(value, 10)
209
+ raise ArgumentError, "#{label} must be in 0..#{max}" unless number.between?(0, max)
210
+
211
+ number
212
+ end
213
+
214
+ # 校验文本输入并保留原字符串,便于上层继续解析原始表达式。
215
+ def string(value, label:)
216
+ raise ArgumentError, "#{label} must be a string" unless value.is_a?(String)
217
+
218
+ value
219
+ end
220
+ end
221
+ end
222
+ end
data/lib/inet/ipv6.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "set"
5
+
6
+ module Inet
7
+ # IPv6 范围独立于 IPv4;JSON 端点使用地址文本,避免客户端整数精度丢失。
8
+ module IPv6
9
+ module_function
10
+
11
+ def address(value)
12
+ validate_text!(value)
13
+ result = ::IPAddr.new(value)
14
+ raise ArgumentError, "Expected IPv6" unless result.ipv6?
15
+
16
+ result
17
+ end
18
+
19
+ def canonical(value, host: false)
20
+ ip = address(value)
21
+ return "#{address(value.split('/').first)}/#{ip.prefix}" if host
22
+
23
+ value.include?("/") ? "#{ip}/#{ip.prefix}" : ip.to_s
24
+ end
25
+
26
+ def parse(value)
27
+ validate_text!(value)
28
+ if value.include?("-")
29
+ parts = value.split("-", -1)
30
+ unless parts.size == 2 && parts.none? { |part| part.include?("/") }
31
+ raise ArgumentError, "IPv6 range needs two hosts"
32
+ end
33
+
34
+ first, last = parts.map { |part| address(part).to_i }
35
+ Set.new(first..last)
36
+ else
37
+ ip = address(value)
38
+ Set.new(ip.to_range.begin.to_i..ip.to_range.end.to_i)
39
+ end
40
+ end
41
+
42
+ def normalize(value)
43
+ validate_text!(value)
44
+ return canonical(value) unless value.include?("-")
45
+
46
+ range = parse(value).ranges.first
47
+ "#{from_i(range.begin)}-#{from_i(range.end)}"
48
+ end
49
+
50
+ def from_i(value)
51
+ unless value.is_a?(Integer) && value.between?(0, ::IPAddr::IN6MASK)
52
+ raise ArgumentError, "IPv6 integer must be in 0..#{::IPAddr::IN6MASK}"
53
+ end
54
+
55
+ ::IPAddr.new(value, Socket::AF_INET6).to_s
56
+ end
57
+
58
+ def ranges(set) = set.map { |range| [from_i(range.begin), from_i(range.end)] }
59
+
60
+ def validate_text!(value)
61
+ return if value.is_a?(String) && value == value.strip && !value.include?("%")
62
+
63
+ raise ArgumentError, "Invalid IPv6 address"
64
+ end
65
+ private_class_method :validate_text!
66
+ end
67
+ end
data/lib/inet/set.rb ADDED
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inet
4
+ # 不变量是区间有序、互不相交且已合并;遍历区间而不是逐个枚举主机。
5
+ class Set
6
+ include Enumerable
7
+
8
+ attr_reader :ranges, :hash
9
+
10
+ # 校验、排序并合并区间,然后冻结集合以保证值语义。
11
+ def initialize(*ranges)
12
+ ordered = ranges.map { |range| interval_endpoints(range) }.sort_by(&:first)
13
+ merged = []
14
+ ordered.each do |first, last|
15
+ if !merged.empty? && first <= merged.last[1] + 1
16
+ merged.last[1] = [merged.last[1], last].max
17
+ else
18
+ merged << [first, last]
19
+ end
20
+ end
21
+ @ranges = merged.map { |first, last| first..last }.freeze
22
+ @hash = @ranges.hash
23
+ freeze
24
+ end
25
+
26
+ # 遍历规范化后的区间;没有代码块时返回枚举器。
27
+ def each(&block)
28
+ return enum_for(__method__) unless block
29
+
30
+ ranges.each(&block)
31
+ self
32
+ end
33
+
34
+ # 判断集合是否没有区间。
35
+ def empty? = ranges.empty?
36
+
37
+ # 返回区间段数量,而不是地址或端口总数。
38
+ def size = ranges.size
39
+
40
+ # 返回最小端点;空集合返回 nil。
41
+ def min = ranges.first&.begin
42
+
43
+ # 返回最大端点;空集合返回 nil。
44
+ def max = ranges.last&.end
45
+
46
+ # 计算所有闭区间包含的元素总数。
47
+ def cardinality = sum { |range| range.end - range.begin + 1 }
48
+
49
+ # 仅按规范化后的区间比较集合内容。
50
+ def ==(other) = other.is_a?(Set) && ranges == other.ranges
51
+
52
+ alias eql? ==
53
+
54
+ # 计算并集;两个集合为空或相同对象时直接复用已有值。
55
+ def |(other)
56
+ validate_operand!(other)
57
+ return self if other.empty? || equal?(other)
58
+ return other if empty?
59
+
60
+ self.class.new(*ranges, *other.ranges)
61
+ end
62
+
63
+ # 用双指针扫描两个有序集合,计算交集。
64
+ def &(other)
65
+ validate_operand!(other)
66
+ return self if equal?(other) || empty?
67
+ return other if other.empty?
68
+
69
+ result = []
70
+ left = right = 0
71
+ while left < size && right < other.size
72
+ a = ranges[left]
73
+ b = other.ranges[right]
74
+ first = [a.begin, b.begin].max
75
+ last = [a.end, b.end].min
76
+ result << (first..last) if first <= last
77
+ left += 1 if a.end <= b.end
78
+ right += 1 if b.end <= a.end
79
+ end
80
+ self.class.new(*result)
81
+ end
82
+
83
+ # 从当前集合中减去另一个集合,逐段截去重叠部分。
84
+ def -(other)
85
+ validate_operand!(other)
86
+ return self if empty? || other.empty?
87
+ return self.class.new if equal?(other)
88
+
89
+ result = []
90
+ right = 0
91
+ each do |range|
92
+ cursor = range.begin
93
+ right += 1 while right < other.size && other.ranges[right].end < cursor
94
+ result.concat(subtract_interval(range, other.ranges, right))
95
+ end
96
+ self.class.new(*result)
97
+ end
98
+
99
+ # 判断整数是否落在任一规范化区间内。
100
+ def include?(number)
101
+ raise ArgumentError, "Membership requires an integer" unless number.is_a?(Integer)
102
+
103
+ candidate = ranges.bsearch { |range| range.end >= number }
104
+ candidate ? candidate.begin <= number : false
105
+ end
106
+
107
+ # 判断集合是否被当前集合覆盖;空集合始终被覆盖。
108
+ def cover?(other)
109
+ return include?(other) if other.is_a?(Integer)
110
+
111
+ validate_operand!(other)
112
+ return covers_interval?(other.ranges.first) if other.size == 1
113
+
114
+ index = 0
115
+ other.each do |range|
116
+ index += 1 while index < size && ranges[index].end < range.begin
117
+ return false if index == size || ranges[index].begin > range.begin || ranges[index].end < range.end
118
+ end
119
+ true
120
+ end
121
+
122
+ # 判断当前集合是否是另一个集合的子集。
123
+ def subset?(other)
124
+ validate_operand!(other)
125
+ other.cover?(self)
126
+ end
127
+
128
+ # 判断两个有序集合是否存在重叠区间。
129
+ def overlap?(other)
130
+ validate_operand!(other)
131
+ left = right = 0
132
+ while left < size && right < other.size
133
+ a = ranges[left]
134
+ b = other.ranges[right]
135
+ return true if a.overlap?(b)
136
+
137
+ a.end < b.end ? left += 1 : right += 1
138
+ end
139
+ false
140
+ end
141
+
142
+ # 返回相等、包含、被包含或互不相同四种关系之一。
143
+ def relation(other)
144
+ validate_operand!(other)
145
+ return :equal if self == other
146
+ return :contains if cover?(other)
147
+ return :within if subset?(other)
148
+
149
+ :other
150
+ end
151
+
152
+ # 返回区间数组的浅副本,避免调用方修改内部数组。
153
+ def to_a = ranges.dup
154
+
155
+ # 哈希值只在当前进程有效;持久化区间,并在读取进程中重新建立冻结集合。
156
+ def marshal_dump = ranges
157
+
158
+ # 从持久化区间恢复集合的不变量和冻结状态。
159
+ def marshal_load(ranges)
160
+ initialize(*ranges)
161
+ end
162
+
163
+ private
164
+
165
+ # 把合法区间统一为闭区间端点。
166
+ def interval_endpoints(range)
167
+ unless range.is_a?(Range) && range.begin.is_a?(Integer) && range.end.is_a?(Integer)
168
+ raise ArgumentError, "Intervals must have integer endpoints"
169
+ end
170
+
171
+ first = range.begin
172
+ last = range.end - (range.exclude_end? ? 1 : 0)
173
+ raise ArgumentError, "Intervals must be nonnegative and ordered" if first.negative? || first > last
174
+
175
+ [first, last]
176
+ end
177
+
178
+ # 对单个区间做差,调用方复用已找到的右侧起点。
179
+ def subtract_interval(range, removed_ranges, scan)
180
+ result = []
181
+ cursor = range.begin
182
+ while scan < removed_ranges.size && removed_ranges[scan].begin <= range.end
183
+ removed = removed_ranges[scan]
184
+ result << (cursor..(removed.begin - 1)) if removed.begin > cursor
185
+ cursor = [cursor, removed.end + 1].max
186
+ break if cursor > range.end
187
+
188
+ scan += 1
189
+ end
190
+ result << (cursor..range.end) if cursor <= range.end
191
+ result
192
+ end
193
+
194
+ # 单区间包含检查使用二分查找。
195
+ def covers_interval?(target)
196
+ candidate = ranges.bsearch { |range| range.end >= target.end }
197
+ candidate ? candidate.begin <= target.begin : false
198
+ end
199
+
200
+ # 统一校验集合运算的右值类型。
201
+ def validate_operand!(other)
202
+ raise ArgumentError, "Expected a Set" unless other.is_a?(Set)
203
+ end
204
+ end
205
+ end
data/lib/inet/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inet
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/inet.rb CHANGED
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "inet/version"
4
+ require_relative "inet/set"
5
+ require_relative "inet/ipaddr"
6
+ require_relative "inet/ipv6"
4
7
 
5
- # inet 的公共命名空间;首版只提供加载入口与版本信息。
8
+ require_relative "inet/domain_name"
9
+ require_relative "inet/dns_resolver"
10
+
11
+ # IP 地址、域名、DNS 解析、服务编码与不可变整数区间集合的公共命名空间。
6
12
  module Inet
7
13
  end
metadata CHANGED
@@ -1,15 +1,86 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - inet contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-07 00:00:00.000000000 Z
11
- dependencies: []
12
- description: The initial inet package provides a loadable namespace and version information.
10
+ date: 2026-10-24 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: digest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ipaddr
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: resolv
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: time
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: Domain matching, cached DNS, IP address ranges, service encoding and
83
+ immutable interval sets.
13
84
  executables: []
14
85
  extensions: []
15
86
  extra_rdoc_files: []
@@ -19,6 +90,11 @@ files:
19
90
  - README.md
20
91
  - docs/releasing.md
21
92
  - lib/inet.rb
93
+ - lib/inet/dns_resolver.rb
94
+ - lib/inet/domain_name.rb
95
+ - lib/inet/ipaddr.rb
96
+ - lib/inet/ipv6.rb
97
+ - lib/inet/set.rb
22
98
  - lib/inet/version.rb
23
99
  homepage: https://github.com/gatework/inet
24
100
  licenses:
@@ -45,5 +121,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
121
  requirements: []
46
122
  rubygems_version: 4.0.20
47
123
  specification_version: 4
48
- summary: Foundation for the inet Ruby library
124
+ summary: IP addresses, domain names, DNS and immutable interval sets
49
125
  test_files: []