winreg 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fbe8c4f07ef7324d292aacf40153e5f25e412fea50699cd344b066159c9fc48a
4
+ data.tar.gz: e9cc1596e5a598c6b3c1243dcafc2e504e95e96519053aec5888b04634a88cbf
5
+ SHA512:
6
+ metadata.gz: b0c0714cd31ca0e62c9cfc141ffde6c9e67cc4f42e645f2787bbed9091aed99f58805dff6df0107eeca06dd6f316fce73662fa70b87a3e3b5347d61f8fdf8ba9
7
+ data.tar.gz: dfc6db4e5ab395ea033b5d691f6df7042e744083954c6bd0a717c04426e2daca3c5335e89c2b2d157502f6c6b0157617b52cf5914820d564cdd5a229b1864ba3
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-01
4
+
5
+ Initial release.
6
+
7
+ - **Typed reads** — strict `string`/`dword`/`qword`/`multi_string`/`binary`
8
+ readers (and their `?` no-raise-on-missing variants) plus a generic
9
+ `read`/`read?` returning `[type, value]`. REG_SZ/REG_EXPAND_SZ decode to UTF-8
10
+ with at most one trailing NUL stripped (unterminated values round-trip);
11
+ REG_EXPAND_SZ is never auto-expanded; REG_MULTI_SZ decodes to `Array<String>`
12
+ tolerating missing/excess terminators; REG_DWORD/REG_QWORD are size-checked.
13
+ - **Typed writes** — `write_string`, `write_expand_string`, `write_multi_string`
14
+ (correct double-NUL wire format), range-checked `write_dword`/`write_qword`,
15
+ `write_binary`, and the generic `write(name, type, value)`. Embedded NULs and
16
+ empty REG_MULTI_SZ elements are rejected, so the gem never produces a malformed
17
+ value.
18
+ - **Raw escape hatches** — `raw`/`write_raw` move exact bytes under an arbitrary
19
+ type tag, never decoding, for forensic and adversarial data.
20
+ - **Default value** — `nil`/`""` addresses the unnamed default value for every
21
+ read/write/delete/probe.
22
+ - **WOW64 views** — `view: :v64`/`:v32` is a first-class constructor option,
23
+ stored on the `Key` and re-applied consistently to every child open/create,
24
+ `delete_key`, `key?` probe, and `Watch` handle; children cannot override it.
25
+ - **Least-privilege opens** — `access: :read` is `KEY_READ`; writes demand an
26
+ explicit `access: :read_write`; `KEY_ALL_ACCESS` appears nowhere.
27
+ - **Enumeration & metadata** — `each_value`/`each_key`/`value_names`/`key_names`
28
+ (16,383-char value names) and `info` (counts + 100ns-precision
29
+ `last_write_time`).
30
+ - **Subkeys** — handle-relative `open`/`create` and `delete_key`
31
+ (`recursive: true` does a view-aware, interruptible, deepest-first walk).
32
+ - **Change notification** — `Key#watch` returns a `Winreg::Watch` (or loops in
33
+ block form); `wait` returns `:changed`/`:deleted`/`nil`. Registrations are
34
+ `REG_NOTIFY_THREAD_AGNOSTIC` and rearmed before delivery (no final state is
35
+ ever missed); waits release the GVL and are interruptible standalone, and run
36
+ cooperatively under a fiber scheduler (winloop) via a worker-thread offload.
37
+ - Error taxonomy under `Winreg::Error`: `OSError` (with `#code`) and its
38
+ `NotFound`/`AccessDenied`/`KeyDeleted` subclasses, plus `TypeMismatch`,
39
+ `MalformedValue`, and `Closed`.
40
+
41
+ Pure C extension over advapi32 + kernel32 (`rb_raise`/longjmp-safe; every handle
42
+ freed by a TypedData finalizer). Windows MSVC (mswin) Ruby only.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ned
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # winreg
2
+
3
+ **Typed Windows registry access for Ruby — exact wire formats, least-privilege opens, WOW64 views as a first-class option, and change notification that cooperates with a fiber scheduler.**
4
+
5
+ Ruby's bundled `win32/registry` is pure Ruby over Fiddle, and it has accumulated
6
+ a decade of wire-format bugs: it writes REG_MULTI_SZ without the trailing empty
7
+ string (a 22-byte image where 24 is correct), it `NoMethodError`s on
8
+ `read(nil)`/`[nil]` for a key's default value, and it `.chop`s the last
9
+ character off any string whose stored bytes are not NUL-terminated. Its
10
+ enumeration name buffer is 514 chars, so a value name of 515+ (the registry
11
+ allows 16,383) raises. And it does not bind `RegNotifyChangeKeyValue` at all, so
12
+ there is no way to watch a key for changes.
13
+
14
+ `winreg` is a thin MSVC C extension that gets the bytes right. It exposes the
15
+ Win32 registry through a typed, hard-to-misuse API: strict typed readers and
16
+ writers that own serialization (so the wire format is correct by construction),
17
+ raw escape hatches for adversarial data, working default-value access,
18
+ range-checked integers, full-length enumeration, `KEY_READ`-not-`KEY_ALL_ACCESS`
19
+ defaults, 32/64-bit views applied consistently to child operations, and change
20
+ watching.
21
+
22
+ The watch surface releases the GVL and is interruptible standalone, and runs
23
+ **cooperatively** under a fiber scheduler such as
24
+ [winloop](https://rubygems.org/gems/winloop) by offloading the blocking wait to a
25
+ worker thread — so a parked watcher never stalls the loop. Plain registry data
26
+ operations are microsecond-scale local calls; they run inline and never park a
27
+ fiber.
28
+
29
+ ## API summary
30
+
31
+ | What | API |
32
+ |---|---|
33
+ | Open / create | `Winreg.open(path, access:, view:)`, `Winreg.create(path, access:, view:)`, `Key#open`, `Key#create` |
34
+ | Typed read | `Key#string` `#dword` `#qword` `#multi_string` `#binary` (+ `?` variants), `#read`/`#read?` → `[type, value]` |
35
+ | Typed write | `Key#write_string` `#write_expand_string` `#write_multi_string` `#write_dword` `#write_qword` `#write_binary`, `#write(name, type, value)`, `#delete_value` |
36
+ | Raw escape hatch | `Key#raw` → `[tag, bytes]`, `Key#write_raw(name, tag, bytes)` |
37
+ | Enumerate | `Key#each_value` `#each_key` `#value_names` `#key_names` |
38
+ | Info | `Key#info` → counts + `last_write_time` |
39
+ | Delete | `Key#delete_value`, `Key#delete_key(name, recursive:)` |
40
+ | Probes | `Key#value?`, `Key#key?` |
41
+ | Views | `view: :default | :v64 | :v32` (inherited by children) |
42
+ | Watch | `Key#watch(subtree:, filter:)` → `Winreg::Watch`, `Watch#wait(timeout:)` |
43
+ | Expand | `Winreg.expand_string(str)`, `Key#string(name, expand: true)` |
44
+
45
+ ## Requirements
46
+
47
+ - **Windows 10 or newer** with a native **MSVC (mswin)** Ruby. Not supported on
48
+ MinGW/UCRT. (The change-notification path relies on
49
+ `REG_NOTIFY_THREAD_AGNOSTIC`, which is Windows 8+.)
50
+ - Visual Studio 2017+ / Build Tools with the **Desktop development with C++** workload.
51
+ - x64. arm64-mswin is expected to work (the code is arch-neutral) but is
52
+ untested and unsupported until an arm64-mswin Ruby distribution exists.
53
+
54
+ ## Install
55
+
56
+ ```sh
57
+ gem install winreg
58
+ ```
59
+
60
+ ## Reading typed values
61
+
62
+ No elevation is needed to read most of `HKLM\SOFTWARE`:
63
+
64
+ ```ruby
65
+ require "winreg"
66
+
67
+ Winreg.open('HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion') do |k|
68
+ k.string("ProductName") # => "Windows 10 ..."
69
+ k.read("CurrentMajorVersionNumber") # => [:dword, 10]
70
+ k.string?("NoSuchValue") # => nil (the ? readers return nil when missing)
71
+ end
72
+ ```
73
+
74
+ `read` returns `[type, value]` where `type` is a Symbol from `Winreg::TYPES`
75
+ (or the raw Integer tag for types outside the table). Strict typed readers
76
+ (`string`, `dword`, …) raise `Winreg::TypeMismatch` if the stored type differs —
77
+ a wrong type is a bug, not an absence, so even the `?` variants raise on a type
78
+ mismatch (they only swallow "missing").
79
+
80
+ ## Writing
81
+
82
+ Writers own serialization, so the wire format is correct by construction
83
+ (double-NUL REG_MULTI_SZ, byte counts including terminators). They require a key
84
+ opened `access: :read_write`:
85
+
86
+ ```ruby
87
+ Winreg.create('HKCU\Software\Vendor\App') do |k|
88
+ k.write_string("InstallDir", 'C:\Vendor\App')
89
+ k.write_expand_string("Cache", '%LOCALAPPDATA%\Vendor')
90
+ k.write_multi_string("Plugins", %w[alpha beta]) # correct double-NUL wire format
91
+ k.write_dword("Port", 8080)
92
+
93
+ k.multi_string("Plugins") # => ["alpha", "beta"]
94
+ k.dword("Port") # => 8080
95
+
96
+ k.write_dword("Port", -1) # raises RangeError (stdlib silently wraps to 0xFFFFFFFF)
97
+ k.dword("InstallDir") # raises Winreg::TypeMismatch (it is REG_SZ)
98
+ end
99
+ ```
100
+
101
+ Integers are unsigned and range-checked (`0..2**32-1` / `0..2**64-1`); embedded
102
+ NULs in string data and empty/NUL-containing REG_MULTI_SZ elements are rejected
103
+ with `ArgumentError` — the gem never produces a malformed value.
104
+
105
+ ## The default value
106
+
107
+ The unnamed (default) value is addressed by `nil` or `""` for every operation —
108
+ the case where the stdlib raises `NoMethodError`:
109
+
110
+ ```ruby
111
+ Winreg.open('HKCU\Software\Classes\CLSID') do |k|
112
+ k.read?(nil) # => [:sz, "..."] or nil
113
+ end
114
+ ```
115
+
116
+ ## Expand strings
117
+
118
+ REG_EXPAND_SZ values are returned **literally** — the `%VAR%` text and the true
119
+ `:expand_sz` type are preserved, never silently expanded or masked to `:sz`.
120
+ Expansion is explicit and uses `ExpandEnvironmentStringsW` (not a Ruby gsub over
121
+ `ENV`, whose semantics differ — unknown variables are left literal exactly as the
122
+ OS defines):
123
+
124
+ ```ruby
125
+ k.string("Cache") # => "%LOCALAPPDATA%\\Vendor" (unexpanded)
126
+ k.string("Cache", expand: true) # => "C:\\Users\\me\\AppData\\Local\\Vendor"
127
+ Winreg.expand_string('%TEMP%\\x') # => "C:\\Users\\me\\AppData\\Local\\Temp\\x"
128
+ ```
129
+
130
+ ## Enumerating
131
+
132
+ ```ruby
133
+ Winreg.open('HKCU\Software\Vendor\App') do |k|
134
+ k.each_value { |name, type, value| puts "#{name} (#{type}) = #{value.inspect}" }
135
+ k.value_names # => ["InstallDir", "Cache", "Plugins", "Port"]
136
+ k.key_names # => subkey names
137
+
138
+ info = k.info
139
+ info.value_count # => Integer
140
+ info.last_write_time # => Time (100ns FILETIME precision)
141
+ end
142
+ ```
143
+
144
+ Enumeration is live and snapshot-less; kernel order is arbitrary and concurrent
145
+ mutation may skip or repeat entries. `each_value` decodes data and so can raise
146
+ `MalformedValue` on a hostile value mid-iteration — use `value_names` + `raw` for
147
+ forensic robustness (it never decodes).
148
+
149
+ ## WOW64 views
150
+
151
+ A 32/64-bit view is a constructor option, stored on the key and **automatically
152
+ re-applied** to every child `open`/`create`, `delete_key`, `key?` probe, and
153
+ `Watch`. Children cannot override it — this is the API encoding of the
154
+ view-consistency rule, so you never accidentally cross the redirection boundary:
155
+
156
+ ```ruby
157
+ Winreg.create('HKCU\Software\Classes\CLSID\{...}', view: :v64) do |k|
158
+ child = k.create("Sub") # also :v64, no way to ask for :v32 here
159
+ end
160
+ ```
161
+
162
+ Avoid addressing `Wow6432Node` literally; use `view:` instead.
163
+
164
+ ## Watching for changes
165
+
166
+ `Key#watch` returns an armed `Winreg::Watch`, or loops in block form. The block
167
+ form yields `:changed` per coalesced change and yields `:deleted` once (then
168
+ returns) when the watched key is deleted:
169
+
170
+ ```ruby
171
+ Winreg.create('HKCU\Software\Vendor\App') do |k|
172
+ k.watch(filter: :values) do |event|
173
+ case event
174
+ when :changed then reload_config!
175
+ when :deleted then break
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ The primitive form takes a timeout (seconds; `nil` = infinite):
182
+
183
+ ```ruby
184
+ w = key.watch(subtree: true)
185
+ case w.wait(timeout: 5)
186
+ when :changed then puts "something under #{key.path} changed"
187
+ when :deleted then puts "key is gone"
188
+ when nil then puts "no change in 5s"
189
+ end
190
+ w.close
191
+ ```
192
+
193
+ `filter:` is a Symbol or Array of `:values`, `:keys`, `:attributes`, `:security`,
194
+ `:default` (= keys + values), or `:all`. Notifications **coalesce** (`:changed`
195
+ means "≥ 1 matching change since the previous delivery") and carry **no
196
+ payload** — no value name, no change kind. The contract is "something changed; go
197
+ look"; diff it yourself (e.g. compare `info.last_write_time` snapshots). The
198
+ registration is rearmed *before* each delivery, so the final state is never
199
+ missed; the cost is that one spurious wakeup is possible. The `Watch` opens its
200
+ own private `KEY_NOTIFY` handle, so closing the originating `Key` does not disturb
201
+ it.
202
+
203
+ ### Fiber schedulers (winloop)
204
+
205
+ `Watch#wait` is the only blocking call. Under a live `Fiber.scheduler` such as
206
+ [winloop](https://rubygems.org/gems/winloop) the native wait is offloaded to a
207
+ worker thread and `Thread#value` parks the calling fiber through the scheduler's
208
+ hooks, so the loop keeps serving other fibers:
209
+
210
+ ```ruby
211
+ require "winloop"
212
+ Winloop.run do
213
+ Fiber.schedule do
214
+ Winreg.create('HKCU\Software\Vendor\App') do |k|
215
+ k.watch(filter: :values) { |e| break if e == :deleted }
216
+ end
217
+ end
218
+ Fiber.schedule { do_other_io }
219
+ end
220
+ ```
221
+
222
+ With no scheduler the same call just blocks the calling thread (releasing the GVL,
223
+ interruptible by `Thread#kill` / `Ctrl-C` / `Timeout`). Caveat: a fiber unwound
224
+ between the worker observing `:changed` and value delivery loses that one
225
+ delivery — harmless, because `:changed` is stateless and the registration stays
226
+ armed, so the next change still fires.
227
+
228
+ ## Raw escape hatches
229
+
230
+ `raw`/`write_raw` move exact bytes under an arbitrary type tag and never decode,
231
+ for adversarial data or types outside the typed surface:
232
+
233
+ ```ruby
234
+ tag, bytes = k.raw("Plugins") # => [7, "a\x00l\x00...\x00\x00\x00\x00"]
235
+ k.write_raw("Custom", 8, bytes) # arbitrary type tag (REG_RESOURCE_LIST here)
236
+ ```
237
+
238
+ ## Errors
239
+
240
+ ```
241
+ StandardError
242
+ └─ Winreg::Error
243
+ ├─ Winreg::OSError # a Windows API failed; #code is the LSTATUS / Win32 code
244
+ │ ├─ Winreg::NotFound # ERROR_FILE_NOT_FOUND (2)
245
+ │ ├─ Winreg::AccessDenied # ERROR_ACCESS_DENIED (5)
246
+ │ └─ Winreg::KeyDeleted # ERROR_KEY_DELETED (1018): the handle is valid, the key is gone
247
+ ├─ Winreg::TypeMismatch # a typed reader's stored type differs
248
+ ├─ Winreg::MalformedValue # stored bytes don't decode as the claimed type
249
+ └─ Winreg::Closed # an operation on a closed Key or Watch
250
+ ```
251
+
252
+ Plain argument mistakes raise Ruby's own `ArgumentError` / `TypeError` /
253
+ `RangeError` (e.g. an integer out of range) — those are misuse, not OS state.
254
+
255
+ ## Notes & limitations
256
+
257
+ - **Notifications coalesce and carry no payload.** A single `:changed` may stand
258
+ for many changes; you get a symbol, not what changed. Diff it yourself.
259
+ - **`RegRestoreKey` deletion is not detected.** A key replaced via
260
+ `RegRestoreKey` does not surface as `:deleted` (an OS limitation).
261
+ - **Names with invalid UTF-16 are lenient-decoded.** Enumeration never aborts on
262
+ a hostile name; such names come back with U+FFFD replacement and therefore
263
+ cannot be round-trip addressed through the gem (a deliberate v1 trade — the
264
+ alternative makes a whole key unenumerable).
265
+ - **Embedded NULs in stored string data are preserved**, not silently truncated
266
+ to the Win32-consumer view (`"a\0b"` reads as `"a\0b"`); writers reject embedded
267
+ NULs so the gem never creates such a value.
268
+ - **Forward slash is a legal key-name character.** The path parser splits on
269
+ backslash only and never translates `/`.
270
+ - **Keep values ≤ ~2 KiB** per Microsoft guidance (not enforced; the only hard
271
+ limit is the 4 GiB DWORD byte-count guard).
272
+ - **HKLM writes need elevation.** 64-bit processes are never virtualized, so a
273
+ non-elevated HKLM write surfaces as an honest `Winreg::AccessDenied`.
274
+ - **Remote registry is unsupported.** A path starting with `\\server\…` raises
275
+ `ArgumentError` at parse time.
276
+ - Every kernel handle lives in a wrapper whose finalizer closes it, so a
277
+ forgotten `#close` never leaks — but closing explicitly (or using the block
278
+ forms) is good manners.
279
+ - **Windows/MSVC only.** Links `advapi32` + `kernel32`, built with `cl.exe`.
280
+
281
+ ## License
282
+
283
+ [MIT](LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the winreg extension — typed Windows registry access and
4
+ # change notification (RegNotifyChangeKeyValue).
5
+
6
+ require "mkmf"
7
+
8
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
9
+ abort <<~MSG
10
+ winreg requires a native Windows MSVC (mswin) Ruby — it binds the Win32
11
+ registry (RegOpenKeyExW/RegSetValueExW/RegNotifyChangeKeyValue) and is
12
+ built with cl.exe. Your Ruby is "#{RbConfig::CONFIG['arch']}".
13
+ MSG
14
+ end
15
+
16
+ # System import libs (bare "NAME.lib" tokens on mswin):
17
+ # advapi32 - registry APIs (RegOpenKeyExW/RegQueryValueExW/RegSetValueExW/
18
+ # RegEnumValueW/RegEnumKeyExW/RegQueryInfoKeyW/RegDeleteKeyExW/
19
+ # RegDeleteValueW/RegNotifyChangeKeyValue)
20
+ # kernel32 (events, WaitForMultipleObjects, ExpandEnvironmentStringsW) is
21
+ # linked by default. Pure C — no -EHsc, so rb_raise/longjmp is the normal,
22
+ # safe error mechanism.
23
+ $libs = [$libs, "advapi32.lib"].join(" ")
24
+
25
+ create_makefile("winreg/winreg")