diff --git a/internal/update/images_windows.go b/internal/update/images_windows.go index 78bb9e4..77e940f 100644 --- a/internal/update/images_windows.go +++ b/internal/update/images_windows.go @@ -68,7 +68,7 @@ func (u *Updater) lockSDKImages() (release func(), err error) { } else if err != nil { return nil, swapBlocked("无法检查模拟器目录 %s(%v),什么都没动", dir, err) } - resolved, err := filepath.EvalSymlinks(dir) + resolved, err := resolvePath(dir) if err != nil { return nil, swapBlocked("无法确认模拟器目录 %s(%v),什么都没动", dir, err) } diff --git a/internal/update/path_identity_windows_test.go b/internal/update/path_identity_windows_test.go new file mode 100644 index 0000000..f82a822 --- /dev/null +++ b/internal/update/path_identity_windows_test.go @@ -0,0 +1,323 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "modavd/internal/jsonord" + "modavd/internal/root" + "modavd/internal/testutil" + "modavd/internal/win" +) + +func updatePathAlias(t *testing.T, target, kind string) string { + t.Helper() + if kind == "short name" { + ptr, err := syscall.UTF16PtrFromString(target) + if err != nil { + t.Fatal(err) + } + var buf [32768]uint16 + n, err := syscall.GetShortPathName(ptr, &buf[0], uint32(len(buf))) + if err != nil || n == 0 || n >= uint32(len(buf)) { + t.Fatalf("GetShortPathName: %d, %v", n, err) + } + alias := syscall.UTF16ToString(buf[:n]) + if strings.EqualFold(alias, target) { + t.Skip("test volume does not create 8.3 names") + } + return alias + } + alias := filepath.Join(testutil.TempDir(t), "alias") + if kind == "symlink" { + if err := os.Symlink(target, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + } else { + cmd := exec.Command("cmd.exe", "/d", "/c", "mklink", "/J", alias, target) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + } + st, err := os.Stat(alias) + if err != nil || !st.IsDir() { + t.Fatalf("invalid directory alias: %v", err) + } + return alias +} + +func TestSelfUpdatePathAliases(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, action := range []string{"check", "apply", "restore", "recover finish", "recover rollback"} { + name := kind + "/alias root/" + action + if reverse { + name = kind + "/alias image/" + action + } + t.Run(name, func(t *testing.T) { + real := filepath.Join(testutil.TempDir(t), "root with long name") + if err := copyFile(self, filepath.Join(real, "tools", "modavd.exe")); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, real, kind) + rootPath, imageRoot := alias, real + if reverse { + rootPath, imageRoot = real, alias + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(imageRoot, "tools", "modavd.exe"), "-test.run=^TestSelfUpdatePathAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_PATH_ALIAS_ROOT="+rootPath, "MODAVD_PATH_ALIAS_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self update: %v\n%s", err, out) + } + }) + } + } + } +} + +func TestSelfUpdatePathAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_PATH_ALIAS_ROOT") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + action := os.Getenv("MODAVD_PATH_ALIAS_ACTION") + if action == "check" { + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, l.at("ModAVD.exe")); err != nil { + t.Fatal(err) + } + occupancyChild(t, l.at("ModAVD.exe")) + plan.replace = append(plan.replace, "ModAVD.exe") + if busy := l.rootFileInUse(plan); busy != "ModAVD.exe" { + t.Fatalf("different running image was exempted: %q", busy) + } + return + } + source := l.pkgAt(rel) + if action == "restore" || action == "recover rollback" { + source = l.prevAt(rel) + } + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write(source, action) + var err error + switch action { + case "apply": + err = l.applyRootFiles(plan) + case "restore": + err = l.restoreRootFiles(plan) + case "recover finish", "recover rollback": + write(l.at("mas-avd.json"), "{}") + if err := os.MkdirAll(l.at("sdk"), 0o755); err != nil { + t.Fatal(err) + } + st := newState(phaseSwapping, "plan", plan.value(), "swap", stepVal(stepOldMoved)) + target := jsonord.NewObject() + target.Obj().Set("version", jsonord.NewString("v0.37.0")) + st.Set("target", target) + if action == "recover finish" { + write(filepath.Join(l.stageEmulator(), "marker"), "new SDK") + staged := jsonord.NewObject() + staged.Obj().Set("verified", jsonord.NewBool(true)) + st.Set("staged", staged) + } else { + write(filepath.Join(l.previousEmulator(), "marker"), "old SDK") + } + if err := l.writeState(st); err != nil { + t.Fatal(err) + } + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + u := New(r, nil, Options{}) + u.scan = ownScan(u) + if _, err := u.recover(); err != nil { + t.Fatal(err) + } + if phaseOf(l.readState()) != phaseIdle || !isDir(l.emulator()) { + t.Fatal("recovery did not commit its result") + } + wantSDK := "old SDK" + if action == "recover finish" { + wantSDK = "new SDK" + } + if got, err := os.ReadFile(filepath.Join(l.emulator(), "marker")); err != nil || string(got) != wantSDK { + t.Fatalf("wrong recovered SDK: %q, %v", got, err) + } + ok, _ := l.readState().Get("lastResult").Obj().Get("ok").BoolVal() + if ok != (action == "recover finish") { + t.Fatalf("wrong recovery result: ok=%v", ok) + } + default: + t.Fatalf("unknown action: %s", action) + } + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q, %v", got, err) + } +} + +func TestPathAliasProcessOwnership(t *testing.T) { + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, rel := range []string{"tools/modavd.exe", "ModAVD.exe", "sdk/emulator/emulator.exe", ".update/previous/emulator/emulator.exe"} { + name := kind + "/alias root/" + rel + if reverse { + name = kind + "/alias image/" + rel + } + t.Run(name, func(t *testing.T) { + r := writeRoot(t, 36, nil) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, filepath.Join(r.Dir, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, r.Dir, kind) + rootPath, imageRoot := alias, r.Dir + if reverse { + rootPath, imageRoot = r.Dir, alias + } + u := New(&root.Root{Dir: rootPath}, nil, Options{}) + if err := u.l.writeState(newState(phaseSwapping)); err != nil { + t.Fatal(err) + } + child := occupancyChild(t, filepath.Join(imageRoot, filepath.FromSlash(rel))) + program := rel == "tools/modavd.exe" || rel == "ModAVD.exe" + for _, programs := range []bool{false, true} { + uses, err := u.scanProcesses(programs, ownProcesses, win.Process.ImagePath) + if err != nil { + t.Fatal(err) + } + want := !program || programs + if !want { + if len(uses) != 0 { + t.Fatalf("program scan disabled: %+v", uses) + } + continue + } + if len(uses) != 1 || uses[0].PID != uint32(child.Process.Pid) || uses[0].RootProgram != program || uses[0].ShutdownHelper { + t.Fatalf("wrong process ownership: %+v", uses) + } + } + if program { + image := filepath.Join(imageRoot, filepath.FromSlash(rel)) + if !u.shutdownHelper(image, []string{image, "__bootwatch", imageRoot, "0", "123", "456"}) { + t.Error("same root's bootwatch was not recognized") + } + if u.shutdownHelper(image, []string{image, "__bootwatch", t.TempDir(), "0", "123", "456"}) { + t.Error("another root's bootwatch was recognized") + } + } + }) + } + } + } +} + +func TestPathAliasSDKInstall(t *testing.T) { + u, _ := imageUpdate(t) + alias := updatePathAlias(t, u.Root.Dir, "junction") + u.Root = &root.Root{Dir: alias} + u.l.root = alias + u.scan = ownScan(u) + if st, err := u.Install(context.Background()); err != nil || !st.Updated || st.Phase != phaseIdle { + t.Fatalf("install through junction: %+v, %v", st, err) + } +} + +func TestPathAliasReadOnlyLoadedImage(t *testing.T) { + dir := testutil.TempDir(t) + alias := updatePathAlias(t, dir, "junction") + path := filepath.Join(dir, "loaded.dll") + free := loadDLL(t, filepath.Join(alias, "loaded.dll")) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + for _, probe := range []string{path, filepath.Join(alias, "loaded.dll")} { + if !loadedImageInUse(probe) { + t.Error("loaded image query missed through junction") + } + if !fileInUse(probe) { + t.Error("read-only loaded image missed through junction") + } + } + free() + if fileInUse(path) { + t.Error("unloaded read-only image is still busy") + } +} + +func TestPathAliasDifferentRoot(t *testing.T) { + r := writeRoot(t, 36, nil) + alias := updatePathAlias(t, r.Dir, "junction") + u := New(&root.Root{Dir: alias}, nil, Options{}) + other := filepath.Join(testutil.TempDir(t), "tools", "modavd.exe") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, other); err != nil { + t.Fatal(err) + } + occupancyChild(t, other) + if uses, err := u.scanProcesses(true, ownProcesses, win.Process.ImagePath); err != nil || len(uses) != 0 { + t.Fatalf("another root's program matched: %+v, %v", uses, err) + } + if u.shutdownHelper(other, []string{other, "__bootwatch", alias, "0", "123", "456"}) { + t.Fatal("another root's program was treated as a shutdown helper") + } +} + +func TestUpdatePathResolutionFailure(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if _, err := resolvePath(missing); err == nil { + t.Fatal("missing path resolved successfully") + } + if got := realOrSame(missing); got != missing { + t.Fatalf("missing path fallback changed: %q", got) + } + if _, err := resolvePath("invalid\x00path"); err == nil { + t.Fatal("invalid path resolved successfully") + } +} diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 90f6af4..4f2d117 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -2,10 +2,15 @@ package update -import "os" +import ( + "os" + "path/filepath" +) func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } // fileInUse: a loaded library or an open file never stops a rename or an unlink here. func fileInUse(string) bool { return false } + +func resolvePath(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 1c01b11..330e693 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -77,25 +77,38 @@ func loadedImageInUse(path string) bool { return false } +// resolvePath returns an extended DOS path suitable for both comparisons and I/O. +// Handle queries resolve junctions and short names without EvalSymlinks. +func resolvePath(path string) (string, error) { + return finalPath(path, 0) +} + func finalImagePath(path string) string { + const volumeNameNT = 2 + name, _ := finalPath(path, volumeNameNT) + return name +} + +func finalPath(path string, flags uint32) (string, error) { ptr, err := syscall.UTF16PtrFromString(path) if err != nil { - return "" + return "", err } h, err := syscall.CreateFile(ptr, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, - nil, syscall.OPEN_EXISTING, 0, 0) + nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) if err != nil { - return "" + return "", err } var name [32768]uint16 - const volumeNameNT = 2 - n, _, _ := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), volumeNameNT) + n, _, err := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), uintptr(flags)) syscall.CloseHandle(h) - if n == 0 || n >= uintptr(len(name)) { - return "" + if n == 0 { + return "", err + } + if n >= uintptr(len(name)) { + return "", syscall.ERROR_INSUFFICIENT_BUFFER } - // Resolve directory links and 8.3 names into the same NT device path. - return syscall.UTF16ToString(name[:n]) + return syscall.UTF16ToString(name[:n]), nil } func processHasImage(pid uint32, target string) bool { diff --git a/internal/update/swap.go b/internal/update/swap.go index 9cc813b..6b2db1f 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -709,7 +709,7 @@ func l2abs(p string) string { } func realOrSame(p string) string { - if r, err := filepath.EvalSymlinks(p); err == nil { + if r, err := resolvePath(p); err == nil { return r } return l2abs(p)