dll_test.go (13698B)
1 //go:build windows 2 3 package main 4 5 import ( 6 "bytes" 7 "fmt" 8 "image" 9 "image/color" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "syscall" 14 "testing" 15 "unsafe" 16 17 "github.com/jackmordaunt/icns/cmd/shell-extension/internal/com" 18 "github.com/jackmordaunt/icns/cmd/shell-extension/internal/provider" 19 "github.com/jackmordaunt/icns/v4" 20 "golang.org/x/sys/windows" 21 ) 22 23 // The test image has three regions: the top-left quarter is opaque red, the 24 // top-right quarter is half-transparent blue, and the bottom half is opaque 25 // green. Region interiors survive the icns encoder's resampling unchanged, so 26 // we can assert exact BGRA values that prove the channel order, that alpha is 27 // premultiplied, and (via the asymmetry) that the bitmap is top-down. 28 var ( 29 topLeftColor = color.NRGBA{R: 255, G: 0, B: 0, A: 255} 30 topRightColor = color.NRGBA{R: 0, G: 0, B: 255, A: 128} 31 bottomColor = color.NRGBA{R: 0, G: 255, B: 0, A: 255} 32 33 topLeftBGRA = [4]byte{0, 0, 255, 255} 34 topRightBGRA = [4]byte{128, 0, 0, 128} // Premultiplied: 255 * 128/255 = 128. 35 bottomBGRA = [4]byte{0, 255, 0, 255} 36 ) 37 38 // checkPixels asserts the three regions of the test image at the expected 39 // positions in a top-down bitmap of the given side length. 40 func checkPixels(t *testing.T, ds dibSection, prefix string) { 41 t.Helper() 42 side := int(ds.Width) 43 for _, tc := range []struct { 44 name string 45 x, y int 46 want [4]byte 47 }{ 48 {"top-left", side / 4, side / 4, topLeftBGRA}, 49 {"top-right", side * 3 / 4, side / 4, topRightBGRA}, 50 {"bottom", side / 2, side * 3 / 4, bottomBGRA}, 51 } { 52 if got := pixel(ds, tc.x, tc.y); got != tc.want { 53 t.Errorf("%s%s pixel BGRA = %v, want %v", prefix, tc.name, got, tc.want) 54 } 55 } 56 } 57 58 // testICNS encodes a 128px icon set (128, 64, 32 and 16 px variants). 59 func testICNS(t *testing.T) []byte { 60 t.Helper() 61 img := image.NewNRGBA(image.Rect(0, 0, 128, 128)) 62 for y := 0; y < 128; y++ { 63 for x := 0; x < 128; x++ { 64 switch { 65 case y >= 64: 66 img.SetNRGBA(x, y, bottomColor) 67 case x < 64: 68 img.SetNRGBA(x, y, topLeftColor) 69 default: 70 img.SetNRGBA(x, y, topRightColor) 71 } 72 } 73 } 74 var buf bytes.Buffer 75 if err := icns.Encode(&buf, img); err != nil { 76 t.Fatalf("encoding test icns: %v", err) 77 } 78 return buf.Bytes() 79 } 80 81 // buildDLL compiles this package as a shared library. The DLL is built outside 82 // t.TempDir because a loaded Go DLL cannot be unloaded, and Windows refuses to 83 // delete a mapped image. 84 func buildDLL(t *testing.T) string { 85 t.Helper() 86 dir, err := os.MkdirTemp("", "icns-shellext-*") 87 if err != nil { 88 t.Fatal(err) 89 } 90 t.Cleanup(func() { _ = os.RemoveAll(dir) }) // Best effort; see above. 91 out := filepath.Join(dir, "icns-shellext.dll") 92 cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", out, ".") 93 cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr 94 if err := cmd.Run(); err != nil { 95 t.Fatalf("building dll: %v", err) 96 } 97 return out 98 } 99 100 // call invokes the method at index of a COM object's vtable. 101 func call(obj unsafe.Pointer, index int, args ...uintptr) com.HRESULT { 102 vtbl := *(**[32]uintptr)(obj) 103 r, _, _ := syscall.SyscallN(vtbl[index], append([]uintptr{uintptr(obj)}, args...)...) 104 return com.HRESULT(uint32(r)) 105 } 106 107 const ( 108 methodQueryInterface = iota 109 methodAddRef 110 methodRelease 111 methodFirst // First method of the derived interface. 112 ) 113 114 func queryInterface(t *testing.T, obj unsafe.Pointer, iid *com.GUID) (unsafe.Pointer, com.HRESULT) { 115 t.Helper() 116 var out unsafe.Pointer 117 hr := call(obj, methodQueryInterface, uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out))) 118 return out, hr 119 } 120 121 func release(obj unsafe.Pointer) uint32 { 122 return uint32(call(obj, methodRelease)) 123 } 124 125 var ( 126 shlwapi = windows.NewLazySystemDLL("shlwapi.dll") 127 procSHCreateMemStream = shlwapi.NewProc("SHCreateMemStream") 128 testGdi32 = windows.NewLazySystemDLL("gdi32.dll") 129 procGetObject = testGdi32.NewProc("GetObjectW") 130 iidIShellItemImageFact = com.MustGUID("{BCC18B79-BA16-442F-80C4-8A59C30C463B}") 131 ) 132 133 func memStream(t *testing.T, data []byte) unsafe.Pointer { 134 t.Helper() 135 r, _, _ := procSHCreateMemStream.Call(uintptr(unsafe.Pointer(unsafe.SliceData(data))), uintptr(len(data))) 136 if r == 0 { 137 t.Fatal("SHCreateMemStream returned nil") 138 } 139 // The stream lives in COM's heap, not Go's; smuggle the address past vet's 140 // uintptr-to-pointer check without a conversion it would flag. 141 var stream unsafe.Pointer 142 *(*uintptr)(unsafe.Pointer(&stream)) = r 143 return stream 144 } 145 146 // dibSection mirrors DIBSECTION from wingdi.h. 147 type dibSection struct { 148 Type int32 149 Width int32 150 Height int32 151 WidthBytes int32 152 Planes uint16 153 BitsPixel uint16 154 Bits unsafe.Pointer 155 Header struct { 156 Size uint32 157 Width int32 158 Height int32 159 Planes uint16 160 BitCount uint16 161 Compression uint32 162 SizeImage uint32 163 XPelsPerMeter int32 164 YPelsPerMeter int32 165 ClrUsed uint32 166 ClrImportant uint32 167 } 168 Bitfields [3]uint32 169 Section windows.Handle 170 Offset uint32 171 } 172 173 func inspect(t *testing.T, hbmp windows.Handle) dibSection { 174 t.Helper() 175 var ds dibSection 176 r, _, err := procGetObject.Call(uintptr(hbmp), unsafe.Sizeof(ds), uintptr(unsafe.Pointer(&ds))) 177 if r == 0 { 178 t.Fatalf("GetObject: %v", err) 179 } 180 return ds 181 } 182 183 func pixel(ds dibSection, x, y int) [4]byte { 184 stride := int(ds.WidthBytes) 185 bits := unsafe.Slice((*byte)(ds.Bits), stride*int(ds.Height)) 186 off := y*stride + x*4 187 return [4]byte(bits[off : off+4]) 188 } 189 190 func TestDLL(t *testing.T) { 191 dll, err := windows.LoadDLL(buildDLL(t)) 192 if err != nil { 193 t.Fatal(err) 194 } 195 getClassObject := dll.MustFindProc("DllGetClassObject") 196 canUnloadNow := dll.MustFindProc("DllCanUnloadNow") 197 198 getFactory := func(clsid, iid *com.GUID) (unsafe.Pointer, com.HRESULT) { 199 var out unsafe.Pointer 200 r, _, _ := getClassObject.Call(uintptr(unsafe.Pointer(clsid)), uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out))) 201 return out, com.HRESULT(uint32(r)) 202 } 203 204 if _, hr := getFactory(com.IID_IStream, com.IID_IClassFactory); hr != com.CLASS_E_CLASSNOTAVAILABLE { 205 t.Fatalf("DllGetClassObject(unknown CLSID) = %#x, want CLASS_E_CLASSNOTAVAILABLE", hr) 206 } 207 if _, hr := getFactory(provider.CLSID, com.IID_IStream); hr != com.E_NOINTERFACE { 208 t.Fatalf("DllGetClassObject(CLSID, IStream) = %#x, want E_NOINTERFACE", hr) 209 } 210 factory, hr := getFactory(provider.CLSID, com.IID_IClassFactory) 211 if hr != com.S_OK || factory == nil { 212 t.Fatalf("DllGetClassObject(CLSID, IClassFactory) = %#x, %p", hr, factory) 213 } 214 if unk, hr := queryInterface(t, factory, com.IID_IUnknown); hr != com.S_OK || unk != factory { 215 t.Fatalf("factory QueryInterface(IUnknown) = %#x, %p; want S_OK, %p", hr, unk, factory) 216 } 217 218 createInstance := func(outer unsafe.Pointer, iid *com.GUID) (unsafe.Pointer, com.HRESULT) { 219 var out unsafe.Pointer 220 hr := call(factory, methodFirst, uintptr(outer), uintptr(unsafe.Pointer(iid)), uintptr(unsafe.Pointer(&out))) 221 return out, hr 222 } 223 if _, hr := createInstance(factory, provider.IID_IInitializeWithStream); hr != com.CLASS_E_NOAGGREGATION { 224 t.Fatalf("CreateInstance(aggregated) = %#x, want CLASS_E_NOAGGREGATION", hr) 225 } 226 if _, hr := createInstance(nil, com.IID_IStream); hr != com.E_NOINTERFACE { 227 t.Fatalf("CreateInstance(IStream) = %#x, want E_NOINTERFACE", hr) 228 } 229 230 data := testICNS(t) 231 232 newProvider := func(t *testing.T) (init, thumb unsafe.Pointer) { 233 t.Helper() 234 init, hr := createInstance(nil, provider.IID_IInitializeWithStream) 235 if hr != com.S_OK || init == nil { 236 t.Fatalf("CreateInstance(IInitializeWithStream) = %#x, %p", hr, init) 237 } 238 stream := memStream(t, data) 239 defer release(stream) 240 if hr := call(init, methodFirst, uintptr(stream), 0); hr != com.S_OK { 241 t.Fatalf("Initialize = %#x", hr) 242 } 243 if hr := call(init, methodFirst, uintptr(stream), 0); hr != com.HRESULT_ALREADY_INITIALIZED { 244 t.Fatalf("second Initialize = %#x, want HRESULT_ALREADY_INITIALIZED", hr) 245 } 246 thumb, hr = queryInterface(t, init, provider.IID_IThumbnailProvider) 247 if hr != com.S_OK || thumb == nil { 248 t.Fatalf("QueryInterface(IThumbnailProvider) = %#x, %p", hr, thumb) 249 } 250 if thumb == init { 251 t.Fatal("IThumbnailProvider and IInitializeWithStream share a vtable pointer") 252 } 253 // COM identity: IUnknown must be the same pointer from every interface. 254 unk, hr := queryInterface(t, thumb, com.IID_IUnknown) 255 if hr != com.S_OK || unk != init { 256 t.Fatalf("QueryInterface(IUnknown) via IThumbnailProvider = %#x, %p; want %p", hr, unk, init) 257 } 258 release(unk) 259 return init, thumb 260 } 261 262 getThumbnail := func(t *testing.T, thumb unsafe.Pointer, cx uint32) (windows.Handle, uint32) { 263 t.Helper() 264 var ( 265 hbmp windows.Handle 266 alpha uint32 267 ) 268 hr := call(thumb, methodFirst, uintptr(cx), uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha))) 269 if hr != com.S_OK { 270 t.Fatalf("GetThumbnail(%d) = %#x", cx, hr) 271 } 272 if hbmp == 0 { 273 t.Fatalf("GetThumbnail(%d) returned a null bitmap", cx) 274 } 275 return hbmp, alpha 276 } 277 278 for _, tc := range []struct { 279 cx uint32 280 want int32 // Expected bitmap side. 281 }{ 282 {cx: 64, want: 64}, // Exact icon size available. 283 {cx: 100, want: 100}, // 128px icon downscaled to fit. 284 {cx: 1000, want: 128}, // Nothing large enough: largest icon, unscaled. 285 } { 286 init, thumb := newProvider(t) 287 hbmp, alpha := getThumbnail(t, thumb, tc.cx) 288 if alpha != provider.WTSAT_ARGB { 289 t.Errorf("cx=%d: alpha = %d, want WTSAT_ARGB", tc.cx, alpha) 290 } 291 ds := inspect(t, hbmp) 292 if ds.Width != tc.want || ds.Height != tc.want { 293 t.Errorf("cx=%d: bitmap is %dx%d, want %dx%d", tc.cx, ds.Width, ds.Height, tc.want, tc.want) 294 } 295 if ds.BitsPixel != 32 || ds.Bits == nil { 296 t.Errorf("cx=%d: bitmap is %dbpp with bits %p, want a 32bpp DIB section", tc.cx, ds.BitsPixel, ds.Bits) 297 } 298 checkPixels(t, ds, fmt.Sprintf("cx=%d: ", tc.cx)) 299 if err := provider.DeleteObject(hbmp); err != nil { 300 t.Error(err) 301 } 302 if n := release(thumb); n != 1 { 303 t.Errorf("cx=%d: Release(thumb) = %d, want 1", tc.cx, n) 304 } 305 if n := release(init); n != 0 { 306 t.Errorf("cx=%d: Release(init) = %d, want 0", tc.cx, n) 307 } 308 } 309 310 // A provider that was never initialised must fail cleanly. 311 init, hr := createInstance(nil, provider.IID_IThumbnailProvider) 312 if hr != com.S_OK { 313 t.Fatalf("CreateInstance(IThumbnailProvider) = %#x", hr) 314 } 315 var ( 316 hbmp windows.Handle 317 alpha uint32 318 ) 319 if hr := call(init, methodFirst, 64, uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha))); hr != com.E_UNEXPECTED { 320 t.Errorf("GetThumbnail before Initialize = %#x, want E_UNEXPECTED", hr) 321 } 322 release(init) 323 324 // Garbage in must not crash the surrogate process. 325 init, _ = createInstance(nil, provider.IID_IInitializeWithStream) 326 garbage := memStream(t, []byte("not an icns file, definitely not")) 327 if hr := call(init, methodFirst, uintptr(garbage), 0); hr != com.S_OK { 328 t.Fatalf("Initialize(garbage) = %#x, want S_OK (validation is deferred)", hr) 329 } 330 release(garbage) 331 thumb, _ := queryInterface(t, init, provider.IID_IThumbnailProvider) 332 if hr := call(thumb, methodFirst, 64, uintptr(unsafe.Pointer(&hbmp)), uintptr(unsafe.Pointer(&alpha))); hr != com.WTS_E_FAILEDEXTRACTION { 333 t.Errorf("GetThumbnail(garbage) = %#x, want WTS_E_FAILEDEXTRACTION", hr) 334 } 335 release(thumb) 336 release(init) 337 338 if r, _, _ := canUnloadNow.Call(); com.HRESULT(uint32(r)) != com.S_FALSE { 339 t.Errorf("DllCanUnloadNow = %#x, want S_FALSE", r) 340 } 341 } 342 343 // TestExplorer asks the shell itself for a thumbnail, which exercises the 344 // registry entries and the out-of-process surrogate. It needs the DLL to be 345 // registered with regsvr32 first, so it only runs when ICNS_SHELLEXT_E2E is set. 346 func TestExplorer(t *testing.T) { 347 if os.Getenv("ICNS_SHELLEXT_E2E") == "" { 348 t.Skip("set ICNS_SHELLEXT_E2E=1 after registering the DLL to run this test") 349 } 350 // A fresh filename per run sidesteps the shell's thumbnail cache. 351 path := filepath.Join(t.TempDir(), "sample.icns") 352 if err := os.WriteFile(path, testICNS(t), 0o644); err != nil { 353 t.Fatal(err) 354 } 355 if err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); err != nil { 356 t.Fatal(err) 357 } 358 defer windows.CoUninitialize() 359 360 var ( 361 shell32 = windows.NewLazySystemDLL("shell32.dll") 362 procSHCreateItemFromParsingName = shell32.NewProc("SHCreateItemFromParsingName") 363 ) 364 var factory unsafe.Pointer 365 r, _, _ := procSHCreateItemFromParsingName.Call( 366 uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(path))), 367 0, 368 uintptr(unsafe.Pointer(iidIShellItemImageFact)), 369 uintptr(unsafe.Pointer(&factory)), 370 ) 371 if hr := com.HRESULT(uint32(r)); hr != com.S_OK { 372 t.Fatalf("SHCreateItemFromParsingName = %#x", hr) 373 } 374 defer release(factory) 375 376 const ( 377 siigbfBiggerSizeOK = 0x01 378 siigbfThumbnailOnly = 0x08 379 size = 128 380 ) 381 // SIZE is passed by value; on x64 an 8-byte struct travels in one register. 382 packedSize := uintptr(size) | uintptr(size)<<32 383 var hbmp windows.Handle 384 hr := call(factory, methodFirst, packedSize, siigbfThumbnailOnly|siigbfBiggerSizeOK, uintptr(unsafe.Pointer(&hbmp))) 385 if hr != com.S_OK || hbmp == 0 { 386 t.Fatalf("IShellItemImageFactory::GetImage = %#x, hbmp %#x (is the DLL registered?)", hr, hbmp) 387 } 388 defer provider.DeleteObject(hbmp) 389 390 ds := inspect(t, hbmp) 391 if ds.Width != size || ds.Height != size { 392 t.Fatalf("shell thumbnail is %dx%d, want %dx%d", ds.Width, ds.Height, size, size) 393 } 394 if ds.Bits == nil { 395 t.Fatal("shell returned a device-dependent bitmap; expected a DIB section") 396 } 397 // The shell may hand back a bottom-up DIB; GetObject reports a positive 398 // header height either way, so probe the top half only. 399 if got := pixel(ds, size/4, size/2); got != topLeftBGRA && got != bottomBGRA { 400 t.Errorf("left pixel BGRA = %v, want %v or %v", got, topLeftBGRA, bottomBGRA) 401 } 402 }