From f45d3a54735cfcaa3317ca998b57127bee467342 Mon Sep 17 00:00:00 2001 From: jinghero <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:42:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=8D=A2=E5=8C=85=E5=89=8D=E6=9F=A5?= =?UTF-8?q?=E5=87=BA=E5=8F=AA=E8=AF=BB=20DLL=20=E5=92=8C=E7=A6=81=E6=AD=A2?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=9A=84=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目录链接下的占用检查、自更新和恢复统一比较真实路径。先查占用再备份,被挡住时保留已核对的暂存包。 --- internal/update/root_busy_windows_test.go | 447 ++++++++++++++++++++++ internal/update/state_windows.go | 137 ++++++- internal/update/swap.go | 17 +- 3 files changed, 585 insertions(+), 16 deletions(-) create mode 100644 internal/update/root_busy_windows_test.go diff --git a/internal/update/root_busy_windows_test.go b/internal/update/root_busy_windows_test.go new file mode 100644 index 0000000..5b9edc3 --- /dev/null +++ b/internal/update/root_busy_windows_test.go @@ -0,0 +1,447 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/testutil" +) + +func TestRootBusyReadOnlyLoadedDLL(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaded.dll") + free := loadDLL(t, path) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + for _, access := range []uint32{syscall.GENERIC_WRITE, 0x00010000} { + h, err := syscall.CreateFile(ptr, access, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + t.Logf("loaded read-only DLL access=%#x: %v", access, err) + if err == nil { + syscall.CloseHandle(h) + } + } + if !fileInUse(path) { + t.Error("loaded read-only DLL was not detected") + } + st, err := os.Stat(path) + if err != nil || st.Mode().Perm()&0o200 != 0 { + t.Fatalf("read-only attribute changed: %v, %v", st, err) + } + free() + if fileInUse(path) { + t.Error("unloaded read-only DLL is busy") + } +} + +func TestRootBusyHandleSharing(t *testing.T) { + for _, tc := range []struct { + name string + share uint32 + busy bool + }{ + {"no sharing", 0, true}, + {"read and write only", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE, true}, + {"all sharing", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE, false}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, tc.share, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + if got := fileInUse(path); got != tc.busy { + t.Errorf("busy = %v, want %v", got, tc.busy) + } + if err := syscall.CloseHandle(h); err != nil { + t.Fatal(err) + } + if fileInUse(path) { + t.Error("closed handle is still busy") + } + if got, err := os.ReadFile(path); err != nil || string(got) != "unchanged" { + t.Fatalf("probe changed file: %q %v", got, err) + } + }) + } + t.Run("os.Open", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "open.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if !fileInUse(path) { + t.Error("os.Open handle was not detected") + } + }) +} + +func TestRootBusyAccessDeniedIsNotUse(t *testing.T) { + dir := t.TempDir() + if fileInUse(dir) { + t.Error("directory access denial counts as busy") + } + path := filepath.Join(dir, "readonly.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("read-only fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("read-only file without a handle counts as busy") + } +} + +// Sharing allows the probes but denies a backup reader. If a busy file is known, +// reaching backupRootFiles would fail here before the updater could report Busy. +func rootBusyBackupGuard(t *testing.T, path string) func() { + t.Helper() + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + open := true + close := func() { + if open { + open = false + syscall.CloseHandle(h) + } + } + t.Cleanup(close) + return close +} + +func TestRootBusyInstallChecksBeforeBackup(t *testing.T) { + const target = "extras/occupied.dll" + for _, mode := range []string{"loaded DLL", "read-only loaded DLL", "no delete sharing", "deleted file"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + oldFiles := map[string]string{"0-backup.txt": "old backup", target: "old DLL"} + newFiles := map[string]string{"0-backup.txt": "new backup", target: "new DLL"} + if mode == "deleted file" { + delete(newFiles, target) + } + r := writeRoot(t, 36, oldFiles) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, newFiles), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + path := u.l.at(target) + var release func() + if mode == "no delete sharing" || mode == "deleted file" { + opened, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + release = func() { opened.Close() } + t.Cleanup(release) + } else { + release = loadDLL(t, path) + if mode == "read-only loaded DLL" { + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + } + } + oldDLL, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + metadata, err := os.ReadFile(u.l.at("mas-avd.json")) + if err != nil { + t.Fatal(err) + } + closeGuard := rootBusyBackupGuard(t, u.l.at("0-backup.txt")) + if _, err := u.Install(context.Background()); errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), filepath.FromSlash(target)) { + t.Fatalf("backup ran before the busy check, or occupant missed: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("stage lost: %+v", st) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatalf("stage no longer valid: %v", err) + } + if lexists(u.l.previous()) { + t.Error("busy install left a backup") + } + if got := read(t, r, "sdk/emulator/emulator.exe"); got != "emulator mas-36" { + t.Fatalf("old SDK changed: %q", got) + } + if got, err := os.ReadFile(path); err != nil || string(got) != string(oldDLL) { + t.Fatalf("occupied file changed: %v", err) + } + if got, err := os.ReadFile(u.l.at("mas-avd.json")); err != nil || string(got) != string(metadata) { + t.Fatalf("metadata changed: %v", err) + } + closeGuard() + release() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("retry: %+v %v", st, err) + } + if mode == "deleted file" { + if lexists(path) { + t.Error("obsolete file survived retry") + } + } else if got := read(t, r, target); got != "new DLL" { + t.Fatalf("retry installed %q", got) + } + }) + } +} + +func TestRootBusySelfThroughDirectoryLink(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, action := range []string{"check", "apply", "restore"} { + t.Run(action, func(t *testing.T) { + dir := testutil.TempDir(t) + real := filepath.Join(dir, "root") + cli := filepath.Join(real, "tools", "modavd.exe") + if err := copyFile(self, cli); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias") + // The target exists, so this is a directory link on Windows too. + if err := os.Symlink(real, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + if st, err := os.Stat(filepath.Join(alias, "tools")); err != nil || !st.IsDir() { + t.Fatalf("invalid directory symlink: %v", err) + } + cmd := exec.Command(cli, "-test.run=^TestRootBusySelfAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_SELF="+alias, "MODAVD_ROOT_BUSY_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self %s through link: %v\n%s", action, err, out) + } + }) + } +} + +func TestRootBusySelfAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_ROOT_BUSY_SELF") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + switch action := os.Getenv("MODAVD_ROOT_BUSY_ACTION"); action { + case "check": + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + case "apply", "restore": + source := l.pkgAt(rel) + if action == "restore" { + source = l.prevAt(rel) + } + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte(action), 0o600); err != nil { + t.Fatal(err) + } + var err error + if action == "apply" { + err = l.applyRootFiles(plan) + } else { + err = l.restoreRootFiles(plan) + } + if err != nil { + t.Fatalf("%s: %v", action, err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q %v", got, err) + } + default: + t.Fatalf("unknown action: %s", action) + } +} + +func TestRootBusyDeniedACLIsNotUse(t *testing.T) { + path := filepath.Join(t.TempDir(), "denied.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + cmd := exec.Command("icacls.exe", append([]string{path}, args...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("icacls: %v\n%s", err, out) + } + } + run("/deny", "*S-1-1-0:(WD,AD,DE)") + t.Cleanup(func() { run("/remove:d", "*S-1-1-0") }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("ACL fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("ACL access denial counts as occupied") + } +} + +func TestRootBusyDeleteSharedReadOnlyFile(t *testing.T) { + const obsolete = "extras/obsolete.txt" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{obsolete: "obsolete"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + path := u.l.at(obsolete) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(h) + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("shared reader blocked obsolete file deletion: %+v %v", st, err) + } + if lexists(path) { + t.Error("obsolete file was not removed") + } +} + +func TestRootBusyExternalReadOnlyDLL(t *testing.T) { + for _, deep := range []bool{false, true} { + t.Run(fmt.Sprint("deep=", deep), func(t *testing.T) { + dir := testutil.TempDir(t) + dll := filepath.Join(dir, "external.dll") + if deep { + dll = filepath.Join(dir, strings.Repeat("nested-", 14), strings.Repeat("nested-", 14), "external.dll") + } + free := loadDLL(t, dll) + free() + if err := os.Chmod(dll, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(dll, 0o666) }) + ready := filepath.Join(dir, "ready") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestRootBusyDLLChild$") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_DLL="+dll, "MODAVD_ROOT_BUSY_READY="+ready) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + input, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := false + t.Cleanup(func() { + input.Close() + if !waited { + cancel() + cmd.Wait() + } + }) + deadline := time.Now().Add(5 * time.Second) + for !lexists(ready) { + if time.Now().After(deadline) { + t.Fatal("DLL child never became ready") + } + time.Sleep(10 * time.Millisecond) + } + // Query through a valid directory link too, so mapped image and + // target filenames must resolve to the same actual file. + alias := filepath.Join(dir, "alias") + probe := dll + if err := os.Symlink(filepath.Dir(dll), alias); err == nil { + probe = filepath.Join(alias, filepath.Base(dll)) + } + if !fileInUse(probe) { + t.Error("another process's read-only DLL was missed") + } + input.Close() + err = cmd.Wait() + waited = true + if err != nil { + t.Fatalf("DLL child: %v\n%s", err, output.String()) + } + if fileInUse(probe) { + t.Error("exited DLL child still blocks the file") + } + }) + } +} + +func TestRootBusyDLLChild(t *testing.T) { + path := os.Getenv("MODAVD_ROOT_BUSY_DLL") + if path == "" { + t.Skip("DLL occupancy fixture only") + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatal(err) + } + defer syscall.FreeLibrary(h) + if err := os.WriteFile(os.Getenv("MODAVD_ROOT_BUSY_READY"), nil, 0o600); err != nil { + t.Fatal(err) + } + var release [1]byte + os.Stdin.Read(release[:]) +} diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 78ae678..3e74feb 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -5,25 +5,150 @@ package update import ( "errors" "os" + "strings" "syscall" + "unsafe" ) const ( fileAttributeReparsePoint = 0x400 errorSharingViolation = syscall.Errno(32) errorLockViolation = syscall.Errno(33) + deleteAccess = 0x00010000 ) -// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded -// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and -// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +// Probe writes (loaded images) and deletes (handles without FILE_SHARE_DELETE). +// Other errors remain the swap's to report; no attributes or contents are changed. func fileInUse(path string) bool { - f, err := os.OpenFile(path, os.O_WRONLY, 0) - if err == nil { + f, writeErr := os.OpenFile(path, os.O_WRONLY, 0) + if writeErr == nil { f.Close() + } + if errors.Is(writeErr, errorSharingViolation) || errors.Is(writeErr, errorLockViolation) { + return true + } + ptr, err := syscall.UTF16PtrFromString(path) + if err == nil { + h, err := syscall.CreateFile(ptr, deleteAccess, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, 0, 0) + if err == nil { + syscall.CloseHandle(h) + } else if errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) { + return true + } + } + // Read-only masks a loaded image's write sharing violation, while DELETE + // alone still succeeds. Confirm an actual loaded image: an ordinary reader + // with delete sharing must not prevent removal of an obsolete read-only file. + if errors.Is(writeErr, syscall.ERROR_ACCESS_DENIED) { + st, err := os.Stat(path) + return err == nil && !st.IsDir() && st.Mode().Perm()&0o200 == 0 && loadedImageInUse(path) + } + return false +} + +var ( + occupancyKernel32 = syscall.NewLazyDLL("kernel32.dll") + enumProcessModulesEx = occupancyKernel32.NewProc("K32EnumProcessModulesEx") + getMappedFileNameW = occupancyKernel32.NewProc("K32GetMappedFileNameW") + getModuleFileNameExW = occupancyKernel32.NewProc("K32GetModuleFileNameExW") + getFinalPathNameW = occupancyKernel32.NewProc("GetFinalPathNameByHandleW") +) + +// loadedImageInUse is best effort, like the write/delete probes. A protected or +// exiting process that cannot be inspected leaves the existing swap/recovery path. +func loadedImageInUse(path string) bool { + target := finalImagePath(path) + if target == "" { + return false + } + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) + if err != nil { + return false + } + defer syscall.CloseHandle(snapshot) + var entry syscall.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + for err := syscall.Process32First(snapshot, &entry); err == nil; err = syscall.Process32Next(snapshot, &entry) { + if processHasImage(entry.ProcessID, target) { + return true + } + } + return false +} + +func finalImagePath(path string) string { + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "" + } + h, err := syscall.CreateFile(ptr, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + return "" + } + var name [32768]uint16 + const volumeNameNT = 2 + n, _, _ := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), volumeNameNT) + syscall.CloseHandle(h) + if n == 0 || n >= uintptr(len(name)) { + return "" + } + // Both APIs return the resolved NT device path. This also covers directory + // links and avoids resolving every module's DOS path through the filesystem. + return syscall.UTF16ToString(name[:n]) +} + +func processHasImage(pid uint32, target string) bool { + const processQueryInformation = 0x0400 + const processVMRead = 0x0010 + process, err := syscall.OpenProcess(processQueryInformation|processVMRead, false, pid) + if err != nil { + return false + } + defer syscall.CloseHandle(process) + modules := make([]syscall.Handle, 128) + var needed uint32 + const listModulesAll = 3 + const handleSize = uint32(unsafe.Sizeof(syscall.Handle(0))) + // The module list can grow between calls. Retry with the reported size, + // then leave an unstable process to the swap's normal error handling. + for attempt := 0; attempt < 3; attempt++ { + size := uint32(len(modules)) * handleSize + ok, _, _ := enumProcessModulesEx.Call(uintptr(process), uintptr(unsafe.Pointer(&modules[0])), uintptr(size), + uintptr(unsafe.Pointer(&needed)), listModulesAll) + if ok == 0 { + return false + } + if needed > size { + modules = make([]syscall.Handle, (needed+handleSize-1)/handleSize) + continue + } + // Toolhelp's MODULEENTRY32 path is limited to MAX_PATH. The mapped name + // accepts full paths and resolves links to the actual image on disk. + var name [32768]uint16 + for _, module := range modules[:needed/handleSize] { + n, _, err := getMappedFileNameW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + // GetMappedFileName can report MORE_DATA for deep paths even with + // this full-sized buffer. Read the loader's path and resolve it then. + if n == 0 && errors.Is(err, syscall.ERROR_MORE_DATA) { + n, _, _ = getModuleFileNameExW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + if n > 0 && n < uintptr(len(name)) && strings.EqualFold(finalImagePath(syscall.UTF16ToString(name[:n])), target) { + return true + } + continue + } + if n == 0 || n >= uintptr(len(name)) { + continue + } + image := syscall.UTF16ToString(name[:n]) + if strings.EqualFold(image, target) { + return true + } + } return false } - return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) + return false } func isReparsePoint(st os.FileInfo) bool { diff --git a/internal/update/swap.go b/internal/update/swap.go index 4c21c01..9cc813b 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -224,7 +224,7 @@ func (l layout) applyRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -249,7 +249,7 @@ func (l layout) rootFileInUse(p rootPlan) string { self := selfExe() for _, rel := range append(append([]string{}, p.replace...), p.delete...) { dst := l.at(rel) - if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + if inFold(p.created, rel) || (self != "" && strings.EqualFold(realOrSame(dst), self)) || !isFile(dst) { continue } if fileInUse(dst) { @@ -272,7 +272,7 @@ func (l layout) restoreRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -289,10 +289,7 @@ func selfExe() string { if err != nil { return "" } - if real, err := filepath.EvalSymlinks(exe); err == nil { - exe = real - } - return exe + return realOrSame(exe) } // ---- swap @@ -325,9 +322,6 @@ func (u *Updater) swapIn() (string, error) { if err != nil { return "", err } - if err := l.backupRootFiles(plan); err != nil { - return "", err - } // A root file another program holds (MAA loads extras\modavd_extras.dll and // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave // the root half swapped until that program exits. Checked while nothing has been changed yet. @@ -335,6 +329,9 @@ func (u *Updater) swapIn() (string, error) { l.backToStaged(st) return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) } + if err := l.backupRootFiles(plan); err != nil { + return "", err + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin)) From 2f5a6ec8c87ec0db067568b93b1487c23a17425d Mon Sep 17 00:00:00 2001 From: jinghero <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:55:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=8D=A0=E7=94=A8=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E6=8A=8A=E6=A8=A1=E5=9D=97=E7=9F=AD=E5=90=8D=E4=B9=9F=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E6=88=90=E7=9C=9F=E5=AE=9E=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/update/root_busy_windows_test.go | 35 ++++++++++++++++++----- internal/update/state_windows.go | 8 ++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/internal/update/root_busy_windows_test.go b/internal/update/root_busy_windows_test.go index 5b9edc3..9198684 100644 --- a/internal/update/root_busy_windows_test.go +++ b/internal/update/root_busy_windows_test.go @@ -9,7 +9,6 @@ import ( "bytes" "context" "errors" - "fmt" "os" "os/exec" "path/filepath" @@ -17,6 +16,7 @@ import ( "syscall" "testing" "time" + "unsafe" "modavd/internal/errs" "modavd/internal/testutil" @@ -359,19 +359,40 @@ func TestRootBusyDeleteSharedReadOnlyFile(t *testing.T) { } func TestRootBusyExternalReadOnlyDLL(t *testing.T) { - for _, deep := range []bool{false, true} { - t.Run(fmt.Sprint("deep=", deep), func(t *testing.T) { + for _, tc := range []struct { + name string + deep, short bool + }{{"deep=false", false, false}, {"deep=true", true, false}, {"short name", false, true}} { + t.Run(tc.name, func(t *testing.T) { dir := testutil.TempDir(t) dll := filepath.Join(dir, "external.dll") - if deep { + if tc.deep { dll = filepath.Join(dir, strings.Repeat("nested-", 14), strings.Repeat("nested-", 14), "external.dll") } - free := loadDLL(t, dll) - free() + if err := copyFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll"), dll); err != nil { + t.Fatal(err) + } if err := os.Chmod(dll, 0o444); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(dll, 0o666) }) + loadPath := dll + if tc.short { + ptr, err := syscall.UTF16PtrFromString(dll) + if err != nil { + t.Fatal(err) + } + var short [32768]uint16 + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetShortPathNameW") + n, _, err := proc.Call(uintptr(unsafe.Pointer(ptr)), uintptr(unsafe.Pointer(&short[0])), uintptr(len(short))) + if n == 0 || n >= uintptr(len(short)) { + t.Fatalf("GetShortPathName: %v", err) + } + loadPath = syscall.UTF16ToString(short[:n]) + if strings.EqualFold(loadPath, dll) { + t.Skip("test volume does not create 8.3 names") + } + } ready := filepath.Join(dir, "ready") self, err := os.Executable() if err != nil { @@ -380,7 +401,7 @@ func TestRootBusyExternalReadOnlyDLL(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() cmd := exec.CommandContext(ctx, self, "-test.run=^TestRootBusyDLLChild$") - cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_DLL="+dll, "MODAVD_ROOT_BUSY_READY="+ready) + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_DLL="+loadPath, "MODAVD_ROOT_BUSY_READY="+ready) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} var output bytes.Buffer cmd.Stdout, cmd.Stderr = &output, &output diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 3e74feb..1c01b11 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -94,8 +94,7 @@ func finalImagePath(path string) string { if n == 0 || n >= uintptr(len(name)) { return "" } - // Both APIs return the resolved NT device path. This also covers directory - // links and avoids resolving every module's DOS path through the filesystem. + // Resolve directory links and 8.3 names into the same NT device path. return syscall.UTF16ToString(name[:n]) } @@ -145,6 +144,11 @@ func processHasImage(pid uint32, target string) bool { if strings.EqualFold(image, target) { return true } + // A mapped name can retain an 8.3 component such as RUNNER~1 even + // though the target's final name is expanded. Resolve both sides. + if strings.EqualFold(finalImagePath(`\\?\GLOBALROOT`+image), target) { + return true + } } return false }