winclip 0.1.0 → 0.1.1

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: f6d5df273deafae1f60beee563bf2c2e98b1485f858be569b2201e35eedf91c1
4
- data.tar.gz: acd8ef65e6cc2b4435ddbc36be9af15fdc4cf8717f05f39d961319bf32132175
3
+ metadata.gz: c0b25c458e941f65768b07785e50fd7ee61591ba4fc40dd7fe6571bba6454ab7
4
+ data.tar.gz: 6465565e678f37e773aba98def6cc4b4f13eaa190441c9017225d24f0c7740be
5
5
  SHA512:
6
- metadata.gz: 9570a2bc063aa31b16d252d04465a5d03deea83e7d8f9e0a86a7c2ce1c9e38801038e6d1e9d2ab12d3f405703d23bb9f467814cd4f546549a8ca86ab51bcdcb4
7
- data.tar.gz: 80080336d80b9de4d1630f6c47a3a6b99d5ed1eb4be6b19f64b12a6c4d046f5e6d8381268bce45a9813f751a006de28cac0d3cebc8e4682bc2ccd1fa85acae9d
6
+ metadata.gz: 94c890ec8e6fb145bed87ea71ecbf2c08512435903e343eb128aed777b554eebf42f18c46e4477d887f718ebe8b9f0fb17d60bcdf2d7a47297f9eb3205fe038f
7
+ data.tar.gz: 90702bfc96e92607e6599ea310b562112107542cac28d75789591f70e887f59be36eaefad893d9d3ed1967a6266b9a28fcd4ffd9e90ff2be5b4ad1642a726017
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.1] - 2026-06-27
10
+
11
+ ### Fixed
12
+ - **Intermittent heap-corruption crash (silent `SIGSEGV`) when using the image
13
+ clipboard.** `Winclip.image` and `Winclip.image=` raised their Ruby exceptions
14
+ (`rb_raise`, which uses `longjmp`) from inside a function frame that also
15
+ carried C++ exception-handling state — a `std::vector` and a `try`/`catch`
16
+ around the WIC decode. On MSVC/x64 that `longjmp` unwinds through the frame and
17
+ corrupts the C++ unwind bookkeeping, poisoning the process heap; the corruption
18
+ detonated later in an unrelated allocation (often the next exception raise) as a
19
+ `STATUS_HEAP_CORRUPTION` with no Ruby `[BUG]` dump — so it surfaced as an
20
+ occasional truncated/aborted run (~2 of 3) rather than a counted test failure.
21
+ All C++/exception-handling work now lives in helper functions; the Ruby-facing
22
+ image methods are plain POD frames that `rb_raise` can unwind safely. Verified
23
+ with a stress harness: the previously ~98%-fatal reject path now runs 0 crashes
24
+ across hundreds of invocations, and the full suite is stable across randomized
25
+ ordering. Added `test/test_com_stability.rb` covering the reject path and image
26
+ round-trips.
27
+
28
+ ### Changed
29
+ - COM/WIC is now initialized once per process instead of an apartment
30
+ init/teardown on every image call — robustness hardening that removes
31
+ needless per-call STA churn (not the cause of the crash above, but flagged
32
+ alongside it).
33
+
34
+ ### Security / robustness
35
+ - Hardened untrusted-DIB pixel-offset handling in `Winclip.image`: the colour-
36
+ mask gap after a 40-byte `BITMAPINFOHEADER` is now also accounted for under
37
+ `BI_ALPHABITFIELDS` (4 masks), not only `BI_BITFIELDS` (3 masks), so pixels are
38
+ located correctly when the OS does not synthesize a `CF_DIBV5` (e.g. some
39
+ remote-desktop clipboards) and only a raw `CF_DIB` is present.
40
+ - The BGRA row stride (`width * 4`) handed to WIC is now computed and bounds-
41
+ checked in 64-bit, rejecting absurd widths instead of letting a 32-bit
42
+ multiply wrap. Both are defensive (bounded, never a heap over-read in prior
43
+ versions); they keep a malformed/hostile clipboard image decoding correctly or
44
+ failing cleanly rather than producing a wrong image.
45
+
9
46
  ## [0.1.0] - 2026-05-30
10
47
 
11
48
  ### Added
@@ -27,5 +64,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
27
64
  returns `nil` rather than raising. Image size math is 64-bit (no overflow).
28
65
  - `available?(name)` resolves custom format names without registering them.
29
66
 
30
- [Unreleased]: https://github.com/main-path/winclip/compare/v0.1.0...HEAD
67
+ [Unreleased]: https://github.com/main-path/winclip/compare/v0.1.1...HEAD
68
+ [0.1.1]: https://github.com/main-path/winclip/compare/v0.1.0...v0.1.1
31
69
  [0.1.0]: https://github.com/main-path/winclip/releases/tag/v0.1.0
@@ -32,8 +32,34 @@
32
32
  #include <cstring>
33
33
  #include <cstdint>
34
34
 
35
+ #ifndef BI_ALPHABITFIELDS
36
+ #define BI_ALPHABITFIELDS 6 /* 40-byte header + 4 colour masks (R,G,B,A) */
37
+ #endif
38
+
35
39
  static VALUE eWinclipError; /* Winclip::Error */
36
40
 
41
+ /* ---------- COM lifetime -------------------------------------------------
42
+ * WIC runs inside an initialized COM apartment. We initialize the STA ONCE for
43
+ * the life of the process and never CoUninitialize between calls, instead of the
44
+ * old per-call CoInitializeEx/CoUninitialize pair: repeatedly creating and
45
+ * finalizing an apartment around a process-global WIC factory is needless churn,
46
+ * and a single long-lived apartment is the standard model for an in-process WIC
47
+ * consumer. (This is robustness hardening. The intermittent heap-corruption
48
+ * crash itself was a separate bug — rb_raise unwinding through a C++ EH frame —
49
+ * fixed via the C++/EH-confining wc_*_image helpers below, not here.) A plain
50
+ * static flag is safe: every Ruby-facing method runs under the GVL on the main
51
+ * thread. */
52
+ static void wc_ensure_com(void) {
53
+ static bool com_inited = false;
54
+ if (com_inited) return;
55
+ /* S_OK (we initialized it), S_FALSE (already STA) and RPC_E_CHANGED_MODE
56
+ * (thread already MTA) are all acceptable — WIC's in-proc objects work in
57
+ * either apartment model. We deliberately never pair this with
58
+ * CoUninitialize. */
59
+ CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
60
+ com_inited = true;
61
+ }
62
+
37
63
  /* ---------- UTF-8 <-> UTF-16 (LocalAlloc'd results) ---------------------- */
38
64
 
39
65
  static wchar_t *utf8_to_utf16(const char *utf8, int len /* -1 = NUL-terminated */) {
@@ -317,9 +343,20 @@ static HRESULT clip_get_image(std::vector<BYTE> &outPng) {
317
343
  hh = (UINT)(rawH < 0 ? -rawH : rawH);
318
344
  if (w == 0 || hh == 0) throw E_FAIL;
319
345
  if (biSize < sizeof(BITMAPINFOHEADER) || biSize > dibSize) throw E_FAIL;
320
-
346
+ /* The BGRA stride (w*4) is handed to WIC's WritePixels as a UINT and is
347
+ * used as a row stride below; reject widths whose stride would not fit a
348
+ * 32-bit UINT so the `w * 4` expressions can never wrap. */
349
+ if ((uint64_t)w * 4 > 0xFFFFFFFFu) throw E_FAIL;
350
+
351
+ /* Locate the pixel array. For a plain 40-byte BITMAPINFOHEADER the colour
352
+ * masks sit between the header and the pixels: 3 DWORDs for BI_BITFIELDS,
353
+ * 4 for BI_ALPHABITFIELDS. V4/V5 headers (biSize >= 108) embed their masks,
354
+ * so no gap is added for them. */
321
355
  SIZE_T pixOffset = biSize;
322
- if (comp == BI_BITFIELDS && biSize == sizeof(BITMAPINFOHEADER)) pixOffset += 3 * sizeof(DWORD);
356
+ if (biSize == sizeof(BITMAPINFOHEADER)) {
357
+ if (comp == BI_BITFIELDS) pixOffset += 3 * sizeof(DWORD);
358
+ else if (comp == BI_ALPHABITFIELDS) pixOffset += 4 * sizeof(DWORD);
359
+ }
323
360
  if (pixOffset > dibSize) throw E_FAIL;
324
361
  BYTE *pixels = dib + pixOffset;
325
362
  SIZE_T pixBytes = dibSize - pixOffset;
@@ -360,7 +397,47 @@ static HRESULT clip_get_image(std::vector<BYTE> &outPng) {
360
397
  GlobalUnlock(h);
361
398
  CloseClipboard();
362
399
  if (FAILED(hr)) return hr;
363
- return bgra_to_png(bgra.data(), w, hh, w * 4, outPng);
400
+ return bgra_to_png(bgra.data(), w, hh, (UINT)((uint64_t)w * 4), outPng);
401
+ }
402
+
403
+ /* ---------- image: C++/EH-confining wrappers ----------------------------
404
+ * The image paths use std::vector and try/catch, which make their enclosing
405
+ * function carry MSVC C++ exception-handling unwind state. rb_raise unwinds via
406
+ * setjmp/longjmp, and on x64 MSVC that longjmp runs frame cleanup as it goes;
407
+ * letting it unwind THROUGH a frame that owns C++ EH state corrupts the unwind
408
+ * bookkeeping and poisons the heap (a later allocation then dies with a silent
409
+ * STATUS_HEAP_CORRUPTION). So all C++ work is confined to these helpers, which
410
+ * NEVER call into Ruby; the Ruby-facing wc_*_image frames below stay pure POD
411
+ * (no destructors, no try/catch) and are the only ones that rb_raise. */
412
+
413
+ /* 0 = ok (result in *hr), 1 = C++ exception (e.g. bad_alloc). Never raises. */
414
+ static int set_image_guarded(const BYTE *png, size_t len, HRESULT *hr) {
415
+ try {
416
+ std::vector<BYTE> in(png, png + len);
417
+ *hr = clip_set_image(in.data(), in.size());
418
+ } catch (...) {
419
+ return 1;
420
+ }
421
+ return 0;
422
+ }
423
+
424
+ /* 0 = ok (*out is a LocalAlloc'd PNG buffer of *outlen bytes, caller LocalFree's),
425
+ * 1 = C++ exception, 2 = absent/undecodable. Never raises. */
426
+ static int get_image_guarded(BYTE **out, size_t *outlen, HRESULT *hr) {
427
+ *out = NULL; *outlen = 0;
428
+ std::vector<BYTE> png;
429
+ try {
430
+ *hr = clip_get_image(png);
431
+ } catch (...) {
432
+ return 1;
433
+ }
434
+ if (FAILED(*hr)) return 2;
435
+ size_t n = png.size();
436
+ BYTE *buf = (BYTE *)LocalAlloc(LMEM_FIXED, n ? n : 1);
437
+ if (!buf) return 1;
438
+ if (n) memcpy(buf, png.data(), n);
439
+ *out = buf; *outlen = n;
440
+ return 0;
364
441
  }
365
442
 
366
443
  /* ---------- format names ------------------------------------------------ */
@@ -453,38 +530,34 @@ static VALUE wc_get_image(VALUE self) {
453
530
  if (!IsClipboardFormatAvailable(CF_DIBV5) && !IsClipboardFormatAvailable(CF_DIB))
454
531
  return Qnil;
455
532
 
456
- HRESULT cohr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
457
- bool com_owned = (cohr == S_OK || cohr == S_FALSE);
533
+ wc_ensure_com(); /* one process-lifetime STA; never CoUninitialize'd */
458
534
 
459
- std::vector<BYTE> png;
460
- HRESULT hr = E_FAIL;
461
- bool threw = false;
462
- try { hr = clip_get_image(png); }
463
- catch (...) { threw = true; } /* e.g. bad_alloc in bgra_to_png (clipboard already closed) */
464
- if (com_owned) CoUninitialize();
535
+ /* Pure-POD frame: all C++/EH is inside get_image_guarded, so the rb_raise
536
+ * below never unwinds through a frame carrying C++ exception state. */
537
+ BYTE *buf = NULL; size_t buflen = 0; HRESULT hr = E_FAIL;
538
+ int rc = get_image_guarded(&buf, &buflen, &hr);
465
539
 
466
- if (threw) rb_raise(eWinclipError, "winclip: out of memory decoding the clipboard image");
540
+ if (rc == 1) rb_raise(eWinclipError, "winclip: out of memory decoding the clipboard image");
467
541
  /* Format present but not decodable (palettized / malformed) -> nil, matching
468
542
  * the "getters return nil when there's no usable content" contract. */
469
- if (FAILED(hr)) return Qnil;
543
+ if (rc == 2) return Qnil;
470
544
 
471
- VALUE s = rb_str_new((char *)png.data(), (long)png.size());
545
+ VALUE s = rb_str_new((char *)buf, (long)buflen);
546
+ LocalFree(buf);
472
547
  rb_enc_associate(s, rb_ascii8bit_encoding());
473
548
  return s;
474
549
  }
475
550
 
476
551
  static VALUE wc_set_image(VALUE self, VALUE png) {
477
552
  StringValue(png);
478
- HRESULT cohr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
479
- bool com_owned = (cohr == S_OK || cohr == S_FALSE);
553
+ wc_ensure_com(); /* one process-lifetime STA; never CoUninitialize'd */
480
554
 
555
+ /* Pure-POD frame: the byte-vector and try/catch live in set_image_guarded so
556
+ * the rb_raise calls below never unwind through a C++ EH frame. */
557
+ const BYTE *p = (const BYTE *)RSTRING_PTR(png);
558
+ size_t n = (size_t)RSTRING_LEN(png);
481
559
  HRESULT hr = E_FAIL;
482
- bool threw = false;
483
- try {
484
- std::vector<BYTE> in((BYTE *)RSTRING_PTR(png), (BYTE *)RSTRING_PTR(png) + RSTRING_LEN(png));
485
- hr = clip_set_image(in.data(), in.size());
486
- } catch (...) { threw = true; } /* e.g. bad_alloc decoding a huge PNG (no clipboard held) */
487
- if (com_owned) CoUninitialize();
560
+ int threw = set_image_guarded(p, n, &hr);
488
561
 
489
562
  if (threw) rb_raise(eWinclipError, "winclip: out of memory encoding the clipboard image");
490
563
  if (FAILED(hr))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Winclip
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: winclip
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ned