返回 DeepSeek-Reasonix
shortcut_migration_windows_test.go
根目录 / internal / appidentity / shortcut_migration_windows_test.go
1 //go:build windows
2
3 package appidentity
4
5 import (
6 "bytes"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "syscall"
13 "testing"
14 "unsafe"
15
16 "golang.org/x/sys/windows"
17 )
18
19 func TestRepairOwnedShortcutMigratesVersionedElectronAndPreservesProperties(t *testing.T) {
20 for _, id := range []string{"Reasonix", ""} {
21 for _, customIcon := range []bool{false, true} {
22 name := id
23 if name == "" {
24 name = "no_identity"
25 }
26 if customIcon {
27 name += "_custom_icon"
28 }
29 t.Run(name, func(t *testing.T) {
30 migrationTestCOM(t)
31 root := t.TempDir()
32 launcher := filepath.Join(root, "reasonix-launcher.exe")
33 versionDir := filepath.Join(root, "versions", "v1.20.0")
34 target := filepath.Join(versionDir, "app", "Reasonix.exe")
35 migrationTestFile(t, launcher)
36 migrationTestFile(t, target)
37 icon, iconIndex := target, int32(0)
38 if customIcon {
39 icon = filepath.Join(t.TempDir(), "custom.ico")
40 iconIndex = 2
41 migrationTestFile(t, icon)
42 }
43 path := filepath.Join(t.TempDir(), "Reasonix.lnk")
44 before := migrationShortcutState{
45 target: target, id: id, arguments: `--session "work space"`,
46 description: "My Reasonix workspace", workingDirectory: filepath.Dir(target),
47 icon: icon, iconIndex: iconIndex, showCmd: 3,
48 }
49 migrationTestShortcut(t, path, before)
50
51 changed, err := repairOwnedShortcut(path, root)
52 if err != nil || !changed {
53 t.Fatalf("repair = (%v, %v), want (true, nil)", changed, err)
54 }
55 got := migrationReadShortcut(t, path)
56 if !migrationSamePath(got.target, launcher) || got.id != "io.reasonix.desktop" {
57 t.Fatalf("migrated target/id = %q/%q, want %q/io.reasonix.desktop", got.target, got.id, launcher)
58 }
59 if got.arguments != before.arguments || got.description != before.description || got.showCmd != before.showCmd {
60 t.Fatalf("repair changed unrelated properties: got %+v, before %+v", got, before)
61 }
62 if !migrationSamePath(got.workingDirectory, root) {
63 t.Fatalf("working directory = %q, want stable installation root %q", got.workingDirectory, root)
64 }
65 wantIcon := launcher
66 if customIcon {
67 wantIcon = icon
68 }
69 if !migrationSamePath(got.icon, wantIcon) || got.iconIndex != iconIndex {
70 t.Fatalf("icon = %q,%d, want %q,%d", got.icon, got.iconIndex, wantIcon, iconIndex)
71 }
72
73 after := migrationReadBytes(t, path)
74 changed, err = repairOwnedShortcut(path, root)
75 if err != nil || changed {
76 t.Fatalf("second repair = (%v, %v), want (false, nil)", changed, err)
77 }
78 if !bytes.Equal(after, migrationReadBytes(t, path)) {
79 t.Fatal("second repair changed an already migrated .lnk")
80 }
81 if err := os.RemoveAll(versionDir); err != nil {
82 t.Fatal(err)
83 }
84 got = migrationReadShortcut(t, path)
85 if _, err := os.Stat(got.target); err != nil {
86 t.Fatalf("shortcut target disappeared when old version was removed: %v", err)
87 }
88 })
89 }
90 }
91 }
92
93 func TestRepairOwnedShortcutLeavesSeparateStudioInstallationsByteIdentical(t *testing.T) {
94 for _, test := range []struct{ name, executable, id string }{
95 {"legacy_studio", "reasonix-studio.exe", "Reasonix"},
96 {"electron_studio", "Reasonix Studio.exe", "io.reasonix.studio"},
97 } {
98 t.Run(test.name, func(t *testing.T) {
99 migrationTestCOM(t)
100 root, studioRoot := t.TempDir(), t.TempDir()
101 migrationTestFile(t, filepath.Join(root, "reasonix-launcher.exe"))
102 target := filepath.Join(studioRoot, test.executable)
103 migrationTestFile(t, target)
104 path := filepath.Join(t.TempDir(), "Reasonix Studio.lnk")
105 migrationTestShortcut(t, path, migrationShortcutState{
106 target: target, id: test.id, arguments: "--workspace studio",
107 description: "Studio", workingDirectory: studioRoot, icon: target, showCmd: 1,
108 })
109 migrationAssertUnchanged(t, path, root)
110 })
111 }
112 }
113
114 func TestRepairOwnedShortcutPreservesExplicitForeignIdentityInsideCurrentInstall(t *testing.T) {
115 for _, id := range []string{"io.reasonix.studio", "dev.reasonix.desktop", "com.example.custom"} {
116 t.Run(id, func(t *testing.T) {
117 migrationTestCOM(t)
118 root := t.TempDir()
119 target := filepath.Join(root, "versions", "v1.20.0", "app", "Reasonix.exe")
120 migrationTestFile(t, target)
121 migrationTestFile(t, filepath.Join(root, "reasonix-launcher.exe"))
122 path := filepath.Join(root, "Reasonix.lnk")
123 migrationTestShortcut(t, path, migrationShortcutState{
124 target: target, id: id, workingDirectory: filepath.Dir(target), icon: target, showCmd: 1,
125 })
126 migrationAssertUnchanged(t, path, root)
127 })
128 }
129 }
130
131 func TestRepairOwnedShortcutRejectsVersionJunctionEscapingCurrentInstall(t *testing.T) {
132 for _, executable := range []string{"reasonix-desktop.exe", filepath.Join("app", "Reasonix.exe")} {
133 t.Run(executable, func(t *testing.T) {
134 migrationTestCOM(t)
135 root, externalRoot := t.TempDir(), t.TempDir()
136 migrationTestFile(t, filepath.Join(root, "reasonix-launcher.exe"))
137 migrationTestFile(t, filepath.Join(externalRoot, executable))
138 versions := filepath.Join(root, "versions")
139 if err := os.MkdirAll(versions, 0o755); err != nil {
140 t.Fatal(err)
141 }
142 junction := filepath.Join(versions, "v1.20.0")
143 if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, externalRoot).CombinedOutput(); err != nil {
144 t.Fatalf("create directory junction: %v: %s", err, output)
145 }
146 target := filepath.Join(junction, executable)
147 path := filepath.Join(root, "Reasonix.lnk")
148 migrationTestShortcut(t, path, migrationShortcutState{
149 target: target, id: "Reasonix", workingDirectory: filepath.Dir(target), icon: target, showCmd: 1,
150 })
151 migrationAssertUnchanged(t, path, root)
152 })
153 }
154 }
155
156 func TestRepairOwnedShortcutRepairsFlatElectronAndPartiallyMigratedLinks(t *testing.T) {
157 for _, test := range []struct {
158 name, target, icon, id string
159 }{
160 {"flat Electron", filepath.Join("app", "Reasonix.exe"), filepath.Join("app", "Reasonix.exe"), "Reasonix"},
161 {"new identity with version target", filepath.Join("versions", "v1.20.0", "app", "Reasonix.exe"), "reasonix-launcher.exe", "io.reasonix.desktop"},
162 {"stable target with version icon", "reasonix-launcher.exe", filepath.Join("versions", "v1.20.0", "app", "Reasonix.exe"), "io.reasonix.desktop"},
163 } {
164 t.Run(test.name, func(t *testing.T) {
165 migrationTestCOM(t)
166 root := t.TempDir()
167 launcher := filepath.Join(root, "reasonix-launcher.exe")
168 target, icon := filepath.Join(root, test.target), filepath.Join(root, test.icon)
169 for _, path := range []string{launcher, target, icon, filepath.Join(root, "reasonix-desktop.exe")} {
170 migrationTestFile(t, path)
171 }
172 path := filepath.Join(root, "Reasonix.lnk")
173 migrationTestShortcut(t, path, migrationShortcutState{
174 target: target, id: test.id, icon: icon,
175 workingDirectory: root, arguments: `--session "saved session"`, description: "Saved workspace", showCmd: 3,
176 })
177 changed, err := repairOwnedShortcut(path, root)
178 if err != nil || !changed {
179 t.Fatalf("repair = (%v, %v), want (true, nil)", changed, err)
180 }
181 got := migrationReadShortcut(t, path)
182 if !migrationSamePath(got.target, launcher) || !migrationSamePath(got.icon, launcher) || got.id != "io.reasonix.desktop" {
183 t.Fatalf("shortcut was not fully migrated: %+v", got)
184 }
185 if got.arguments != `--session "saved session"` || got.description != "Saved workspace" || got.showCmd != 3 || !migrationSamePath(got.workingDirectory, root) {
186 t.Fatalf("repair changed unrelated properties: %+v", got)
187 }
188 before := migrationReadBytes(t, path)
189 changed, err = repairOwnedShortcut(path, root)
190 if err != nil || changed || !bytes.Equal(before, migrationReadBytes(t, path)) {
191 t.Fatalf("second repair must leave shortcut unchanged: changed=%v err=%v", changed, err)
192 }
193 })
194 }
195 }
196
197 type migrationShortcutState struct {
198 target, id, arguments, description, workingDirectory, icon string
199 iconIndex, showCmd int32
200 }
201
202 func migrationTestCOM(t *testing.T) {
203 t.Helper()
204 runtime.LockOSThread()
205 uninitialize, err := initializeCOM()
206 if err != nil {
207 runtime.UnlockOSThread()
208 t.Fatal(err)
209 }
210 t.Cleanup(func() { uninitialize(); runtime.UnlockOSThread() })
211 }
212
213 func migrationTestFile(t *testing.T, path string) {
214 t.Helper()
215 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
216 t.Fatal(err)
217 }
218 if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
219 t.Fatal(err)
220 }
221 }
222
223 func migrationTestShortcut(t *testing.T, path string, state migrationShortcutState) {
224 t.Helper()
225 createTestShortcut(t, path, state.target)
226 s, err := loadShortcut(path, stgmReadWrite)
227 if err != nil {
228 t.Fatal(err)
229 }
230 defer s.release()
231 for _, field := range []struct {
232 method uintptr
233 value string
234 }{
235 {s.link.VTable.SetArguments, state.arguments},
236 {s.link.VTable.SetDescription, state.description},
237 {s.link.VTable.SetWorkingDirectory, state.workingDirectory},
238 } {
239 value, err := windows.UTF16PtrFromString(field.value)
240 if err != nil {
241 t.Fatal(err)
242 }
243 hr, _, _ := syscall.SyscallN(field.method, uintptr(unsafe.Pointer(s.link)), uintptr(unsafe.Pointer(value)))
244 if err := checkHRESULT("set shortcut fixture property", hr); err != nil {
245 t.Fatal(err)
246 }
247 }
248 icon, err := windows.UTF16PtrFromString(state.icon)
249 if err != nil {
250 t.Fatal(err)
251 }
252 hr, _, _ := syscall.SyscallN(s.link.VTable.SetIconLocation, uintptr(unsafe.Pointer(s.link)), uintptr(unsafe.Pointer(icon)), uintptr(state.iconIndex))
253 if err := checkHRESULT("SetIconLocation", hr); err != nil {
254 t.Fatal(err)
255 }
256 hr, _, _ = syscall.SyscallN(s.link.VTable.SetShowCmd, uintptr(unsafe.Pointer(s.link)), uintptr(state.showCmd))
257 if err := checkHRESULT("SetShowCmd", hr); err != nil {
258 t.Fatal(err)
259 }
260 // The existing setter commits and persists all Shell Link properties.
261 if err := s.setAppUserModelID(state.id); err != nil {
262 t.Fatal(err)
263 }
264 }
265
266 func migrationReadShortcut(t *testing.T, path string) migrationShortcutState {
267 t.Helper()
268 s, err := loadShortcut(path, stgmReadWrite)
269 if err != nil {
270 t.Fatal(err)
271 }
272 defer s.release()
273 var state migrationShortcutState
274 state.target, err = s.targetPath()
275 if err != nil {
276 t.Fatal(err)
277 }
278 state.id, err = s.appUserModelID()
279 if err != nil {
280 t.Fatal(err)
281 }
282 for _, field := range []struct {
283 method uintptr
284 value *string
285 }{
286 {s.link.VTable.GetArguments, &state.arguments},
287 {s.link.VTable.GetDescription, &state.description},
288 {s.link.VTable.GetWorkingDirectory, &state.workingDirectory},
289 } {
290 buffer := make([]uint16, windowsPathBuffer)
291 hr, _, _ := syscall.SyscallN(field.method, uintptr(unsafe.Pointer(s.link)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)))
292 if err := checkHRESULT("get shortcut property", hr); err != nil {
293 t.Fatal(err)
294 }
295 *field.value = windows.UTF16ToString(buffer)
296 }
297 buffer := make([]uint16, windowsPathBuffer)
298 hr, _, _ := syscall.SyscallN(s.link.VTable.GetIconLocation, uintptr(unsafe.Pointer(s.link)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)), uintptr(unsafe.Pointer(&state.iconIndex)))
299 if err := checkHRESULT("GetIconLocation", hr); err != nil {
300 t.Fatal(err)
301 }
302 state.icon = windows.UTF16ToString(buffer)
303 hr, _, _ = syscall.SyscallN(s.link.VTable.GetShowCmd, uintptr(unsafe.Pointer(s.link)), uintptr(unsafe.Pointer(&state.showCmd)))
304 if err := checkHRESULT("GetShowCmd", hr); err != nil {
305 t.Fatal(err)
306 }
307 return state
308 }
309
310 func migrationReadBytes(t *testing.T, path string) []byte {
311 t.Helper()
312 data, err := os.ReadFile(path)
313 if err != nil {
314 t.Fatal(err)
315 }
316 return data
317 }
318
319 func migrationAssertUnchanged(t *testing.T, path, root string) {
320 t.Helper()
321 before := migrationReadBytes(t, path)
322 changed, err := repairOwnedShortcut(path, root)
323 if err != nil || changed {
324 t.Fatalf("foreign shortcut repair = (%v, %v), want (false, nil)", changed, err)
325 }
326 if !bytes.Equal(before, migrationReadBytes(t, path)) {
327 t.Fatal("foreign shortcut bytes changed")
328 }
329 }
330
331 func migrationSamePath(left, right string) bool {
332 if strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) {
333 return true
334 }
335 leftInfo, leftErr := os.Stat(left)
336 rightInfo, rightErr := os.Stat(right)
337 return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)
338 }
339
340 func TestRepairShortcutsLeavesReadOnlyShortcutByteIdentical(t *testing.T) {
341 migrationTestCOM(t)
342 root := t.TempDir()
343 launcher := filepath.Join(root, "reasonix-launcher.exe")
344 target := filepath.Join(root, "versions", "v1.20.0", "app", "Reasonix.exe")
345 migrationTestFile(t, launcher)
346 migrationTestFile(t, target)
347 path := filepath.Join(root, "Reasonix.lnk")
348 migrationTestShortcut(t, path, migrationShortcutState{
349 target: target, id: "Reasonix", icon: target, workingDirectory: filepath.Dir(target), showCmd: 1,
350 })
351 before := migrationReadBytes(t, path)
352 pathPtr, err := windows.UTF16PtrFromString(path)
353 if err != nil {
354 t.Fatal(err)
355 }
356 attributes, err := windows.GetFileAttributes(pathPtr)
357 if err != nil {
358 t.Fatal(err)
359 }
360 // Registered after TempDir so the read-only flag is removed before its cleanup.
361 t.Cleanup(func() {
362 if err := windows.SetFileAttributes(pathPtr, attributes); err != nil {
363 t.Errorf("restore shortcut file attributes: %v", err)
364 }
365 })
366 if err := windows.SetFileAttributes(pathPtr, attributes|windows.FILE_ATTRIBUTE_READONLY); err != nil {
367 t.Fatal(err)
368 }
369 if err := RepairShortcuts(root, []string{path}); err == nil {
370 t.Fatal("repair of a read-only shortcut must return an error")
371 }
372 if !bytes.Equal(before, migrationReadBytes(t, path)) {
373 t.Fatal("failed repair changed the read-only shortcut bytes")
374 }
375 }
376
377 func TestRepairShortcutsValidatesAllPathsBeforeWritingAnyShortcut(t *testing.T) {
378 migrationTestCOM(t)
379 root := t.TempDir()
380 launcher := filepath.Join(root, "reasonix-launcher.exe")
381 target := filepath.Join(root, "versions", "v1.20.0", "app", "Reasonix.exe")
382 migrationTestFile(t, launcher)
383 migrationTestFile(t, target)
384 path := filepath.Join(root, "Reasonix.lnk")
385 migrationTestShortcut(t, path, migrationShortcutState{
386 target: target, id: "Reasonix", icon: target, workingDirectory: filepath.Dir(target), showCmd: 1,
387 })
388 before := migrationReadBytes(t, path)
389 if err := RepairShortcuts(root, []string{path, "relative.lnk"}); err == nil {
390 t.Fatal("repair must reject the later relative path")
391 }
392 if !bytes.Equal(before, migrationReadBytes(t, path)) {
393 t.Fatal("repair wrote the first shortcut before validating the full path list")
394 }
395 }
396
397 // Existing custom links must retain their launch context when only the stable
398 // executable name changes. Exercise actual COM persistence, not just policy.
399 func TestCanonicalShortcutPreservesCustomLaunchContext(t *testing.T) {
400 migrationTestCOM(t)
401 root := t.TempDir()
402 legacy, canonical := filepath.Join(root, "reasonix-launcher.exe"), filepath.Join(root, "Reasonix.exe")
403 migrationTestFile(t, legacy)
404 migrationTestFile(t, canonical)
405 customDir := t.TempDir()
406 icon := filepath.Join(customDir, "custom.ico")
407 migrationTestFile(t, icon)
408 link := filepath.Join(root, "Reasonix custom.lnk")
409 before := migrationShortcutState{
410 target: legacy, id: AppUserModelID, arguments: `--session "work space"`,
411 description: "Custom workspace", workingDirectory: customDir,
412 icon: icon, iconIndex: 2, showCmd: 3,
413 }
414 migrationTestShortcut(t, link, before)
415 if changed, err := repairOwnedShortcut(link, root); err != nil || !changed {
416 t.Fatalf("repair=%v, %v", changed, err)
417 }
418 got := migrationReadShortcut(t, link)
419 if !migrationSamePath(got.target, canonical) {
420 t.Fatalf("target=%q", got.target)
421 }
422 if got.arguments != before.arguments || got.description != before.description || got.showCmd != before.showCmd ||
423 got.iconIndex != before.iconIndex || !migrationSamePath(got.icon, icon) || !migrationSamePath(got.workingDirectory, customDir) || got.id != before.id {
424 t.Fatalf("custom context changed: before=%+v after=%+v", before, got)
425 }
426 if changed, err := repairOwnedShortcut(link, root); err != nil || changed {
427 t.Fatalf("second repair=%v, %v", changed, err)
428 }
429 }
430
430 lines GO