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.
data/lib/winreg.rb ADDED
@@ -0,0 +1,795 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "winreg/version"
4
+ require "winreg/winreg" # native extension: Key/Watch classes + errors + HKEY_* roots
5
+
6
+ # winreg — typed Windows registry access for Ruby: exact wire formats,
7
+ # least-privilege opens, WOW64 views as a first-class option, and change
8
+ # notification that cooperates with a fiber scheduler.
9
+ #
10
+ # Winreg.open('HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion') do |k|
11
+ # k.string("ProductName") # => "Windows 10 ..."
12
+ # k.read("CurrentMajorVersionNumber") # => [:dword, 10]
13
+ # end
14
+ #
15
+ # Winreg.create('HKCU\Software\Vendor\App') do |k|
16
+ # k.write_multi_string("Plugins", %w[alpha beta]) # correct double-NUL wire format
17
+ # k.watch(filter: :values) { |event| break if event == :deleted }
18
+ # end
19
+ module Winreg
20
+ # Registry type tags (verified, winnt.h) — Symbol <-> tag map.
21
+ TYPES = {
22
+ none: 0, sz: 1, expand_sz: 2, binary: 3, dword: 4,
23
+ dword_be: 5, link: 6, multi_sz: 7, qword: 11
24
+ }.freeze
25
+
26
+ TYPE_NAMES = TYPES.invert.freeze
27
+ private_constant :TYPE_NAMES
28
+
29
+ # ---- Win32 flag values (verified, winnt.h) ------------------------------
30
+ KEY_QUERY_VALUE = 0x0001
31
+ KEY_NOTIFY = 0x0010
32
+ KEY_READ = 0x20019 # STANDARD_RIGHTS_READ | QUERY_VALUE | ENUMERATE_SUB_KEYS | NOTIFY
33
+ KEY_WRITE = 0x20006 # STANDARD_RIGHTS_WRITE | SET_VALUE | CREATE_SUB_KEY
34
+ KEY_WOW64_64KEY = 0x0100
35
+ KEY_WOW64_32KEY = 0x0200
36
+ REG_NOTIFY_CHANGE_NAME = 0x1
37
+ REG_NOTIFY_CHANGE_ATTRIBUTES = 0x2
38
+ REG_NOTIFY_CHANGE_LAST_SET = 0x4
39
+ REG_NOTIFY_CHANGE_SECURITY = 0x8
40
+
41
+ # Root tokens (case-insensitive, short and long forms) -> predefined handle.
42
+ ROOTS = {
43
+ "HKCR" => HKEY_CLASSES_ROOT, "HKEY_CLASSES_ROOT" => HKEY_CLASSES_ROOT,
44
+ "HKCU" => HKEY_CURRENT_USER, "HKEY_CURRENT_USER" => HKEY_CURRENT_USER,
45
+ "HKLM" => HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE" => HKEY_LOCAL_MACHINE,
46
+ "HKU" => HKEY_USERS, "HKEY_USERS" => HKEY_USERS,
47
+ "HKCC" => HKEY_CURRENT_CONFIG, "HKEY_CURRENT_CONFIG" => HKEY_CURRENT_CONFIG
48
+ }.freeze
49
+ private_constant :ROOTS
50
+
51
+ ROOT_NAMES = {
52
+ HKEY_CLASSES_ROOT => "HKEY_CLASSES_ROOT",
53
+ HKEY_CURRENT_USER => "HKEY_CURRENT_USER",
54
+ HKEY_LOCAL_MACHINE => "HKEY_LOCAL_MACHINE",
55
+ HKEY_USERS => "HKEY_USERS",
56
+ HKEY_CURRENT_CONFIG => "HKEY_CURRENT_CONFIG"
57
+ }.freeze
58
+ private_constant :ROOT_NAMES
59
+
60
+ # FILETIME epoch (1601-01-01) offset from the Unix epoch, in 100ns ticks.
61
+ FILETIME_EPOCH_DELTA = 116_444_736_000_000_000
62
+ private_constant :FILETIME_EPOCH_DELTA
63
+
64
+ # A Windows API failure carries the originating error code (the LSTATUS /
65
+ # Win32 code), set on the exception in C.
66
+ class OSError
67
+ def code
68
+ @code
69
+ end
70
+ end
71
+
72
+ module_function
73
+
74
+ # Run a blocking native call cooperatively. Under a Fiber scheduler (e.g.
75
+ # winloop) the call is offloaded to a worker Thread so the calling fiber
76
+ # parks (Thread#value routes through the scheduler) and the event loop keeps
77
+ # serving other fibers; with no scheduler it runs inline (the C call already
78
+ # releases the GVL). On fiber unwind the worker is killed+joined so it can't
79
+ # leak. Registry watch registrations are REG_NOTIFY_THREAD_AGNOSTIC, so the
80
+ # ephemeral workers are sound (a registration does not die with its thread).
81
+ #
82
+ # Caveat: a fiber unwound (e.g. Timeout) after the worker observed :changed
83
+ # but before value delivery loses that one delivery — harmless here, because
84
+ # :changed is stateless ("go look") and the registration stays armed.
85
+ def run_blocking
86
+ sched = Fiber.scheduler
87
+ return yield unless sched
88
+
89
+ worker = Thread.new do
90
+ Thread.current.report_on_exception = false
91
+ yield
92
+ end
93
+ begin
94
+ worker.value
95
+ ensure
96
+ if worker.alive?
97
+ worker.kill
98
+ worker.join
99
+ end
100
+ end
101
+ end
102
+
103
+ # Seconds (nil = infinite) -> milliseconds for the C layer (-1 = INFINITE).
104
+ # Never collapse a tiny-but-positive wait into a non-blocking poll.
105
+ def ms_for(timeout)
106
+ return -1 if timeout.nil?
107
+
108
+ t = Float(timeout)
109
+ raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative?
110
+
111
+ ms = (t * 1000).round
112
+ ms.zero? && t.positive? ? 1 : ms
113
+ end
114
+
115
+ # Open an existing key. access: :read (default) | :read_write | Integer raw
116
+ # samDesired (must not contain the WOW64 view bits — the view comes from
117
+ # view:). view: :default | :v64 | :v32. Yields the Key (ensure-closed) if a
118
+ # block is given, returning the block's value.
119
+ def open(path, access: :read, view: :default)
120
+ root, sub = parse_path(path)
121
+ sam = access_mask(access) | view_mask(view)
122
+ k = Key.send(:_open, root, sub, sam)
123
+ k.send(:_init_meta, root, sub, view)
124
+ return k unless block_given?
125
+
126
+ begin
127
+ yield k
128
+ ensure
129
+ k.close
130
+ end
131
+ end
132
+
133
+ # Open-or-create a key (creates all missing intermediate keys). Default
134
+ # access is :read_write — you create keys to write to them. The security
135
+ # descriptor is inherited from the parent (NULL SECURITY_ATTRIBUTES).
136
+ def create(path, access: :read_write, view: :default)
137
+ root, sub = parse_path(path)
138
+ sam = access_mask(access) | view_mask(view)
139
+ k = sub.nil? ? Key.send(:_open, root, nil, sam) : Key.send(:_create, root, sub, sam)
140
+ k.send(:_init_meta, root, sub, view)
141
+ return k unless block_given?
142
+
143
+ begin
144
+ yield k
145
+ ensure
146
+ k.close
147
+ end
148
+ end
149
+
150
+ # Expand %VAR% references using ExpandEnvironmentStringsW (NOT a Ruby gsub
151
+ # over ENV — semantics differ: unknown vars stay literal, exactly as the OS
152
+ # defines). Input/output UTF-8 String.
153
+ def expand_string(str)
154
+ _expand(str)
155
+ end
156
+
157
+ private_class_method :_expand
158
+
159
+ # "<ROOT>\sub\key" -> [root_handle_value, subpath_or_nil]. Backslash is the
160
+ # ONLY separator; forward slash is a legal key-name character and is never
161
+ # translated. One trailing backslash is stripped.
162
+ def parse_path(path)
163
+ s = String(path)
164
+ if s.start_with?("\\\\")
165
+ raise ArgumentError, "winreg: remote registry is not supported"
166
+ end
167
+
168
+ s = s[0..-2] if s.end_with?("\\")
169
+ tok, rest = s.split("\\", 2)
170
+ root = ROOTS[tok.to_s.upcase]
171
+ unless root
172
+ raise ArgumentError,
173
+ "winreg: unknown registry root #{tok.inspect} (accepted: HKCR, HKCU, HKLM, HKU, " \
174
+ "HKCC or their long HKEY_* forms)"
175
+ end
176
+ rest = nil if rest && rest.empty?
177
+ [root, rest]
178
+ end
179
+ private_class_method :parse_path
180
+
181
+ def access_mask(access)
182
+ case access
183
+ when :read then KEY_READ
184
+ when :read_write then KEY_READ | KEY_WRITE
185
+ when Integer
186
+ if (access & (KEY_WOW64_64KEY | KEY_WOW64_32KEY)) != 0
187
+ raise ArgumentError,
188
+ "winreg: raw access mask must not contain the WOW64 view bits (0x300); " \
189
+ "pass view: :v64 / :v32 instead"
190
+ end
191
+ access
192
+ else
193
+ raise ArgumentError, "winreg: access must be :read, :read_write or an Integer mask, " \
194
+ "got #{access.inspect}"
195
+ end
196
+ end
197
+ private_class_method :access_mask
198
+
199
+ def view_mask(view)
200
+ case view
201
+ when :default then 0
202
+ when :v64 then KEY_WOW64_64KEY
203
+ when :v32 then KEY_WOW64_32KEY
204
+ else raise ArgumentError, "winreg: view must be :default, :v64 or :v32, got #{view.inspect}"
205
+ end
206
+ end
207
+ private_class_method :view_mask
208
+
209
+ def filter_mask(filter)
210
+ syms = filter.is_a?(Array) ? filter : [filter]
211
+ raise ArgumentError, "winreg: watch filter must not be empty" if syms.empty?
212
+
213
+ syms.reduce(0) do |mask, sym|
214
+ mask | case sym
215
+ when :values then REG_NOTIFY_CHANGE_LAST_SET
216
+ when :keys then REG_NOTIFY_CHANGE_NAME
217
+ when :attributes then REG_NOTIFY_CHANGE_ATTRIBUTES
218
+ when :security then REG_NOTIFY_CHANGE_SECURITY
219
+ when :default then REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET
220
+ when :all
221
+ REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_ATTRIBUTES |
222
+ REG_NOTIFY_CHANGE_LAST_SET | REG_NOTIFY_CHANGE_SECURITY
223
+ else
224
+ raise ArgumentError, "winreg: unknown watch filter #{sym.inspect} (accepted: " \
225
+ ":values, :keys, :attributes, :security, :default, :all)"
226
+ end
227
+ end
228
+ end
229
+ private_class_method :filter_mask
230
+
231
+ # An open registry key. Instances come only from Winreg.open/.create and
232
+ # Key#open/#create (no public .new). All methods raise Winreg::Closed on a
233
+ # closed key except close/closed?/path/view/created?/inspect.
234
+ class Key
235
+ # The C constructors are plumbing; the validated keyword API above is the
236
+ # only way in (phylax's private-bridge discipline).
237
+ private_class_method :_open, :_create
238
+
239
+ # RegQueryInfoKeyW snapshot. last_write_time is a Time with full 100ns
240
+ # FILETIME precision (Rational arithmetic).
241
+ Info = Struct.new(:subkey_count, :value_count, :max_subkey_name_len,
242
+ :max_value_name_len, :max_value_data_bytes, :last_write_time)
243
+
244
+ # Canonical full path, long-form root ("HKEY_CURRENT_USER\\Software\\X").
245
+ attr_reader :path
246
+
247
+ # :default | :v64 | :v32
248
+ attr_reader :view
249
+
250
+ def inspect
251
+ closed? ? "#<Winreg::Key (closed)>" : "#<Winreg::Key #{@path} view=#{@view.inspect}>"
252
+ end
253
+
254
+ # ---- reading ----------------------------------------------------------
255
+
256
+ # Generic typed read -> [type, value]. type is a Symbol from Winreg::TYPES
257
+ # (or the raw Integer tag for types outside the table). Strings are
258
+ # returned UNEXPANDED with at most one trailing NUL stripped; :multi_sz as
259
+ # Array<String>; integers range-decoded; :binary/:none/:link/unknown as
260
+ # raw BINARY bytes.
261
+ def read(name)
262
+ t, bytes = _read_raw(norm_name(name))
263
+ [Winreg.send(:type_symbol, t), Winreg.send(:decode_value, t, bytes)]
264
+ end
265
+
266
+ # Like #read, but nil when the value does not exist.
267
+ def read?(name)
268
+ read(name)
269
+ rescue Winreg::NotFound
270
+ nil
271
+ end
272
+
273
+ # REG_SZ or REG_EXPAND_SZ -> String (UTF-8), unexpanded unless expand:
274
+ # true (ExpandEnvironmentStringsW on the result, whatever the type).
275
+ def string(name, expand: false)
276
+ bytes = typed_bytes(name, [TYPES[:sz], TYPES[:expand_sz]], "REG_SZ/REG_EXPAND_SZ")
277
+ s = Winreg.send(:decode_string, bytes)
278
+ expand ? Winreg.expand_string(s) : s
279
+ end
280
+
281
+ # nil for a MISSING value (TypeMismatch still raises — a wrong type is a
282
+ # bug, not an absence). Same pattern for the other ? readers.
283
+ def string?(name, expand: false)
284
+ string(name, expand: expand)
285
+ rescue Winreg::NotFound
286
+ nil
287
+ end
288
+
289
+ def dword(name)
290
+ Winreg.send(:decode_int, typed_bytes(name, [TYPES[:dword]], "REG_DWORD"), 4, false, :dword)
291
+ end
292
+
293
+ def dword?(name)
294
+ dword(name)
295
+ rescue Winreg::NotFound
296
+ nil
297
+ end
298
+
299
+ def qword(name)
300
+ Winreg.send(:decode_int, typed_bytes(name, [TYPES[:qword]], "REG_QWORD"), 8, false, :qword)
301
+ end
302
+
303
+ def qword?(name)
304
+ qword(name)
305
+ rescue Winreg::NotFound
306
+ nil
307
+ end
308
+
309
+ def multi_string(name)
310
+ Winreg.send(:decode_multi, typed_bytes(name, [TYPES[:multi_sz]], "REG_MULTI_SZ"))
311
+ end
312
+
313
+ def multi_string?(name)
314
+ multi_string(name)
315
+ rescue Winreg::NotFound
316
+ nil
317
+ end
318
+
319
+ def binary(name)
320
+ typed_bytes(name, [TYPES[:binary]], "REG_BINARY")
321
+ end
322
+
323
+ def binary?(name)
324
+ binary(name)
325
+ rescue Winreg::NotFound
326
+ nil
327
+ end
328
+
329
+ # Raw escape hatch: NEVER decodes, never raises MalformedValue.
330
+ # -> [Integer type_tag, String bytes (Encoding::BINARY)]
331
+ def raw(name)
332
+ _read_raw(norm_name(name))
333
+ end
334
+
335
+ # ---- writing ----------------------------------------------------------
336
+
337
+ # All writers return nil and require access: :read_write (else the OS
338
+ # raises AccessDenied). The gem owns serialization, so the wire format is
339
+ # correct by construction (double-NUL REG_MULTI_SZ, cb incl. terminators).
340
+
341
+ def write_string(name, str)
342
+ _write_raw(norm_name(name), TYPES[:sz], Winreg.send(:encode_sz, str))
343
+ nil
344
+ end
345
+
346
+ def write_expand_string(name, str)
347
+ _write_raw(norm_name(name), TYPES[:expand_sz], Winreg.send(:encode_sz, str))
348
+ nil
349
+ end
350
+
351
+ def write_multi_string(name, ary)
352
+ _write_raw(norm_name(name), TYPES[:multi_sz], Winreg.send(:encode_multi, ary))
353
+ nil
354
+ end
355
+
356
+ def write_dword(name, int)
357
+ v = Winreg.send(:int_in_range, int, 0xFFFF_FFFF, "REG_DWORD")
358
+ _write_raw(norm_name(name), TYPES[:dword], [v].pack("V"))
359
+ nil
360
+ end
361
+
362
+ def write_qword(name, int)
363
+ v = Winreg.send(:int_in_range, int, 0xFFFF_FFFF_FFFF_FFFF, "REG_QWORD")
364
+ _write_raw(norm_name(name), TYPES[:qword], [v].pack("Q<"))
365
+ nil
366
+ end
367
+
368
+ def write_binary(name, bytes)
369
+ Winreg.send(:string_arg, bytes, "binary data")
370
+ _write_raw(norm_name(name), TYPES[:binary], bytes)
371
+ nil
372
+ end
373
+
374
+ # Generic typed write — symmetric with #read; validates identically to the
375
+ # typed writers (it dispatches to them). :dword_be packs 4 bytes BE; :none
376
+ # expects bytes; :link raises (links are read-surface only in v1); a raw
377
+ # Integer tag goes through #write_raw.
378
+ def write(name, type, value)
379
+ case type
380
+ when :sz then write_string(name, value)
381
+ when :expand_sz then write_expand_string(name, value)
382
+ when :multi_sz then write_multi_string(name, value)
383
+ when :dword then write_dword(name, value)
384
+ when :qword then write_qword(name, value)
385
+ when :dword_be
386
+ v = Winreg.send(:int_in_range, value, 0xFFFF_FFFF, "REG_DWORD_BIG_ENDIAN")
387
+ _write_raw(norm_name(name), TYPES[:dword_be], [v].pack("N"))
388
+ when :binary then write_binary(name, value)
389
+ when :none
390
+ Winreg.send(:string_arg, value, ":none data")
391
+ _write_raw(norm_name(name), TYPES[:none], value)
392
+ when :link
393
+ raise ArgumentError, "winreg: :link values are read-surface only (v1); " \
394
+ "registry symlink creation is out of scope"
395
+ when Integer then write_raw(name, type, value)
396
+ else
397
+ raise ArgumentError, "winreg: unknown registry type #{type.inspect}"
398
+ end
399
+ nil
400
+ end
401
+
402
+ # Raw escape hatch, symmetric with #raw: exact bytes under an arbitrary
403
+ # type tag. No validation beyond bytes being a String + the 4 GiB guard.
404
+ def write_raw(name, type_tag, bytes)
405
+ raise TypeError, "winreg: type tag must be an Integer, got #{type_tag.inspect}" unless type_tag.is_a?(Integer)
406
+
407
+ Winreg.send(:string_arg, bytes, "raw data")
408
+ _write_raw(norm_name(name), type_tag, bytes)
409
+ nil
410
+ end
411
+
412
+ # Delete a value (nil/"" addresses the default value). NotFound if absent.
413
+ def delete_value(name)
414
+ _delete_value(norm_name(name))
415
+ nil
416
+ end
417
+
418
+ # ---- existence probes --------------------------------------------------
419
+
420
+ # RegQueryValueExW size probe; error 2 -> false.
421
+ def value?(name)
422
+ _value_p(norm_name(name))
423
+ end
424
+
425
+ # NotFound -> false; ACCESS_DENIED -> true (the key exists, you may not
426
+ # open it); anything else raises. View-consistent (probe carries the view).
427
+ def key?(name)
428
+ probe = _open_child(subpath_arg(name), Winreg::KEY_QUERY_VALUE)
429
+ probe.close
430
+ true
431
+ rescue Winreg::NotFound
432
+ false
433
+ rescue Winreg::AccessDenied
434
+ true
435
+ end
436
+
437
+ # ---- subkeys -----------------------------------------------------------
438
+
439
+ # Child open. Relative path, backslashes allowed ("A\\B"). The parent's
440
+ # WOW64 view is inherited automatically and CANNOT be overridden (the API
441
+ # encoding of the view-consistency rule). Block form ensure-closes.
442
+ def open(subpath, access: :read)
443
+ sam = Winreg.send(:access_mask, access)
444
+ k = _open_child(subpath_arg(subpath), sam)
445
+ k.send(:_init_meta, @root, child_subpath(subpath), @view)
446
+ return k unless block_given?
447
+
448
+ begin
449
+ yield k
450
+ ensure
451
+ k.close
452
+ end
453
+ end
454
+
455
+ # Child open-or-create, same view rules.
456
+ def create(subpath, access: :read_write)
457
+ sam = Winreg.send(:access_mask, access)
458
+ k = _create_child(subpath_arg(subpath), sam)
459
+ k.send(:_init_meta, @root, child_subpath(subpath), @view)
460
+ return k unless block_given?
461
+
462
+ begin
463
+ yield k
464
+ ensure
465
+ k.close
466
+ end
467
+ end
468
+
469
+ # Delete the named child subkey (handle-relative; the view is applied via
470
+ # RegDeleteKeyExW samDesired). Non-recursive delete of a key that still
471
+ # has subkeys fails with an OSError; recursive: true performs a deepest-
472
+ # first walk in Ruby over the raise-safe primitives (interruptible; NOT
473
+ # atomic — same as RegDeleteTreeW), with the view applied to every open.
474
+ def delete_key(name, recursive: false)
475
+ n = subpath_arg(name)
476
+ if recursive
477
+ _delete_tree(n)
478
+ else
479
+ begin
480
+ _delete_key(n)
481
+ rescue Winreg::AccessDenied => e
482
+ err = Winreg::AccessDenied.new("#{e.message} (key may have subkeys; use recursive: true)")
483
+ err.instance_variable_set(:@code, e.code)
484
+ raise err
485
+ end
486
+ end
487
+ nil
488
+ end
489
+
490
+ # ---- enumeration / metadata ---------------------------------------------
491
+
492
+ # Yields (name, type, value) decoded exactly as #read. Live, snapshot-less,
493
+ # kernel order arbitrary; concurrent mutation may skip or repeat entries.
494
+ # A value whose DATA is malformed raises MalformedValue mid-iteration —
495
+ # use #value_names + #raw for forensic robustness.
496
+ def each_value
497
+ return enum_for(:each_value) unless block_given?
498
+
499
+ i = 0
500
+ while (entry = _enum_value(i))
501
+ name, t, bytes = entry
502
+ yield name, Winreg.send(:type_symbol, t), Winreg.send(:decode_value, t, bytes)
503
+ i += 1
504
+ end
505
+ self
506
+ end
507
+
508
+ # Yields each subkey name.
509
+ def each_key
510
+ return enum_for(:each_key) unless block_given?
511
+
512
+ i = 0
513
+ while (name = _enum_key(i))
514
+ yield name
515
+ i += 1
516
+ end
517
+ self
518
+ end
519
+
520
+ # Value names only — never decodes data, so hostile values can't abort it.
521
+ def value_names
522
+ out = []
523
+ i = 0
524
+ while (entry = _enum_value(i))
525
+ out << entry[0]
526
+ i += 1
527
+ end
528
+ out
529
+ end
530
+
531
+ def key_names
532
+ out = []
533
+ each_key { |n| out << n }
534
+ out
535
+ end
536
+
537
+ def info
538
+ a = _query_info
539
+ t = Time.at((a[5] - Winreg.send(:filetime_epoch_delta)) / 10_000_000r)
540
+ Info.new(a[0], a[2], a[1], a[3], a[4], t).freeze
541
+ end
542
+
543
+ # ---- watching ------------------------------------------------------------
544
+
545
+ # Without a block: an armed Winreg::Watch (changes between construction
546
+ # and the first #wait are caught). With a block: loops, yielding :changed
547
+ # per coalesced change; yields :deleted once and returns if the watched
548
+ # key is deleted; the Watch is ensure-closed. The Watch opens its OWN
549
+ # private KEY_NOTIFY|view handle from this key's path — closing this Key
550
+ # afterwards does not disturb it.
551
+ def watch(subtree: false, filter: :default)
552
+ raise Winreg::Closed, "winreg: key is closed" if closed?
553
+
554
+ mask = Winreg.send(:filter_mask, filter)
555
+ vsam = Winreg.send(:view_mask, @view || :default)
556
+ w = Watch.send(:_new, @root, @subpath, vsam, subtree ? true : false, mask)
557
+ return w unless block_given?
558
+
559
+ begin
560
+ loop do
561
+ event = w.wait
562
+ yield event
563
+ break if event == :deleted
564
+ end
565
+ nil
566
+ ensure
567
+ w.close
568
+ end
569
+ end
570
+
571
+ private
572
+
573
+ # Set by the Ruby layer right after _open/_create — ivars, not native-
574
+ # struct VALUEs, so the rb_data_type_t needs no dmark.
575
+ def _init_meta(root, subpath, view)
576
+ @root = root
577
+ @subpath = subpath
578
+ @view = view
579
+ root_name = Winreg.send(:root_name, root)
580
+ @path = subpath ? "#{root_name}\\#{subpath}" : root_name
581
+ self
582
+ end
583
+
584
+ # Value names: String, or nil/"" for the key's default (unnamed) value
585
+ # ("" normalized to nil; C passes NULL).
586
+ def norm_name(name)
587
+ return nil if name.nil?
588
+ raise TypeError, "winreg: value name must be a String or nil, got #{name.inspect}" unless name.is_a?(String)
589
+
590
+ name.empty? ? nil : name
591
+ end
592
+
593
+ # Subkey names/paths must be real Strings (nil has no meaning here).
594
+ def subpath_arg(name)
595
+ raise TypeError, "winreg: subkey name must be a String, got #{name.inspect}" unless name.is_a?(String)
596
+ raise ArgumentError, "winreg: subkey name must not be empty" if name.empty?
597
+
598
+ name
599
+ end
600
+
601
+ def child_subpath(subpath)
602
+ @subpath ? "#{@subpath}\\#{subpath}" : subpath.dup
603
+ end
604
+
605
+ # Strict typed read: bytes if the stored tag is in +allowed+, else
606
+ # TypeMismatch naming expected vs actual.
607
+ def typed_bytes(name, allowed, expected_desc)
608
+ t, bytes = _read_raw(norm_name(name))
609
+ unless allowed.include?(t)
610
+ actual = Winreg.send(:type_symbol, t)
611
+ expected = allowed.map { |tag| Winreg.send(:type_symbol, tag).inspect }.join(" or ")
612
+ raise Winreg::TypeMismatch,
613
+ "winreg: value #{name.inspect} has type #{actual.inspect}, expected #{expected} " \
614
+ "(#{expected_desc})"
615
+ end
616
+ bytes
617
+ end
618
+
619
+ # Deepest-first recursive delete: always enumerate index 0 (children shift
620
+ # down as they are deleted); every child open is ensure-closed; interrupts
621
+ # are delivered between primitive calls (no C frame holds a child HKEY
622
+ # across a potential raise).
623
+ def _delete_tree(name)
624
+ child = _open_child(name, Winreg::KEY_READ)
625
+ begin
626
+ while (sub = child.send(:_enum_key, 0))
627
+ child.send(:_delete_tree, sub)
628
+ end
629
+ ensure
630
+ child.close
631
+ end
632
+ _delete_key(name)
633
+ end
634
+ end
635
+
636
+ # A change-notification registration (constructed only via Key#watch).
637
+ # Armed at construction and re-armed before every delivery, so no final
638
+ # state is ever missed; deliveries coalesce (:changed means ">= 1 matching
639
+ # change since the previous delivery") and carry no payload.
640
+ class Watch
641
+ private_class_method :_new
642
+
643
+ # -> :changed (rearmed; ready to wait again)
644
+ # :deleted (watched key was deleted; the watch auto-closes)
645
+ # nil (timeout elapsed; registration still armed)
646
+ # timeout in seconds (nil = infinite). Cooperates with a fiber scheduler
647
+ # via Winreg.run_blocking; standalone it releases the GVL and is
648
+ # interruptible. Raises Closed if closed (incl. closed by another thread
649
+ # mid-wait); Winreg::Error if another wait is already in flight
650
+ # (single-waiter); OSError on a rearm failure other than ERROR_KEY_DELETED.
651
+ def wait(timeout: nil)
652
+ ms = Winreg.ms_for(timeout)
653
+ Winreg.run_blocking { _wait(ms) }
654
+ end
655
+ end
656
+
657
+ # ---- internal codecs (validation + exact wire formats) -------------------
658
+ # These are module-private: the public surface is Key's typed methods.
659
+
660
+ def type_symbol(tag)
661
+ TYPE_NAMES[tag] || tag
662
+ end
663
+ private_class_method :type_symbol
664
+
665
+ def root_name(root)
666
+ ROOT_NAMES[root]
667
+ end
668
+ private_class_method :root_name
669
+
670
+ def filetime_epoch_delta
671
+ FILETIME_EPOCH_DELTA
672
+ end
673
+ private_class_method :filetime_epoch_delta
674
+
675
+ def decode_value(tag, bytes)
676
+ case tag
677
+ when TYPES[:sz], TYPES[:expand_sz] then decode_string(bytes)
678
+ when TYPES[:multi_sz] then decode_multi(bytes)
679
+ when TYPES[:dword] then decode_int(bytes, 4, false, :dword)
680
+ when TYPES[:dword_be] then decode_int(bytes, 4, true, :dword_be)
681
+ when TYPES[:qword] then decode_int(bytes, 8, false, :qword)
682
+ else bytes # :binary, :none, :link, unknown tags: raw BINARY bytes
683
+ end
684
+ end
685
+ private_class_method :decode_value
686
+
687
+ # E1: returned bytes are ground truth; strip AT MOST one trailing UTF-16 NUL
688
+ # code unit (never an unconditional chop — unterminated values round-trip).
689
+ # E2/E3: odd byte counts and invalid UTF-16 raise MalformedValue.
690
+ def decode_string(bytes)
691
+ if bytes.bytesize.odd?
692
+ raise MalformedValue,
693
+ "winreg: string value has an odd byte count (#{bytes.bytesize})"
694
+ end
695
+ b = bytes
696
+ b = b.byteslice(0, b.bytesize - 2) if b.bytesize >= 2 && b.getbyte(-1).zero? && b.getbyte(-2).zero?
697
+ utf16le_to_utf8(b)
698
+ end
699
+ private_class_method :decode_string
700
+
701
+ # E6: tolerate missing final terminator, 0-byte data, and excess
702
+ # terminators — decode the whole buffer, split on NUL, stop at the first
703
+ # empty element (an empty string terminates the list by definition).
704
+ def decode_multi(bytes)
705
+ if bytes.bytesize.odd?
706
+ raise MalformedValue,
707
+ "winreg: REG_MULTI_SZ value has an odd byte count (#{bytes.bytesize})"
708
+ end
709
+ return [] if bytes.empty?
710
+
711
+ out = []
712
+ utf16le_to_utf8(bytes).split("\0", -1).each do |part|
713
+ break if part.empty?
714
+
715
+ out << part
716
+ end
717
+ out
718
+ end
719
+ private_class_method :decode_multi
720
+
721
+ def decode_int(bytes, size, big_endian, type)
722
+ unless bytes.bytesize == size
723
+ raise MalformedValue,
724
+ "winreg: #{type.inspect} value has #{bytes.bytesize} bytes (expected #{size})"
725
+ end
726
+ if size == 4
727
+ bytes.unpack1(big_endian ? "N" : "V")
728
+ else
729
+ bytes.unpack1("Q<")
730
+ end
731
+ end
732
+ private_class_method :decode_int
733
+
734
+ def utf16le_to_utf8(bytes)
735
+ bytes.dup.force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8)
736
+ rescue EncodingError => e
737
+ raise MalformedValue, "winreg: string value is not valid UTF-16LE (#{e.message})"
738
+ end
739
+ private_class_method :utf16le_to_utf8
740
+
741
+ # Writers: NUL/empty-element validation BEFORE encoding; Encoding::*Error
742
+ # surfaces as ArgumentError (with cause).
743
+ def encode_sz(str)
744
+ string_arg(str, "string data")
745
+ raise ArgumentError, "winreg: string data must not contain an embedded NUL" if str.include?("\0")
746
+
747
+ utf8_to_utf16le(str) + "\0\0".b
748
+ end
749
+ private_class_method :encode_sz
750
+
751
+ def encode_multi(ary)
752
+ raise TypeError, "winreg: multi_string takes an Array of Strings, got #{ary.inspect}" unless ary.is_a?(Array)
753
+
754
+ ary.each do |el|
755
+ unless el.is_a?(String)
756
+ raise ArgumentError, "winreg: multi_string elements must be Strings, got #{el.inspect}"
757
+ end
758
+ if el.empty?
759
+ raise ArgumentError, "winreg: multi_string elements must not be empty " \
760
+ "(an empty element terminates the list)"
761
+ end
762
+ if el.include?("\0")
763
+ raise ArgumentError, "winreg: multi_string elements must not contain an embedded NUL"
764
+ end
765
+ end
766
+ return "\0\0".b if ary.empty? # the 2-byte empty list
767
+
768
+ ary.map { |el| utf8_to_utf16le(el) + "\0\0".b }.join + "\0\0".b
769
+ end
770
+ private_class_method :encode_multi
771
+
772
+ def utf8_to_utf16le(str)
773
+ str.encode(Encoding::UTF_16LE).force_encoding(Encoding::BINARY)
774
+ rescue EncodingError
775
+ raise ArgumentError, "winreg: string data cannot be encoded as UTF-16"
776
+ end
777
+ private_class_method :utf8_to_utf16le
778
+
779
+ def int_in_range(value, max, what)
780
+ raise TypeError, "winreg: #{what} value must be an Integer, got #{value.inspect}" unless value.is_a?(Integer)
781
+ unless value >= 0 && value <= max
782
+ raise RangeError, "winreg: #{what} must be in 0..#{max} (got #{value}); " \
783
+ "registry integers are unsigned"
784
+ end
785
+ value
786
+ end
787
+ private_class_method :int_in_range
788
+
789
+ def string_arg(value, what)
790
+ raise TypeError, "winreg: #{what} must be a String, got #{value.inspect}" unless value.is_a?(String)
791
+
792
+ value
793
+ end
794
+ private_class_method :string_arg
795
+ end