From 12436217aed5d6bde1a51cab281dfd87127221c0 Mon Sep 17 00:00:00 2001 From: Roger Date: Wed, 25 Mar 2026 23:54:22 +0800 Subject: [PATCH] feat: add filemanager session and crypto version detection (#516) * feat: add filemanager session and crypto version detection * refactor: move copy logic into filemanager, remove fileutil dependency * fix: apply review suggestions for filemanager * feat: add Windows locked file tests, fix readFileContent with ReadFile+FileMapping fallback * fix: remove self-PID skip in findFileHandle to fix Windows CI test * fix: seek to file start before reading duplicated handle * fix: use full path matching in findFileHandle to avoid cross-app handle collision * test: enhance Windows copyLocked tests with write-then-read, large file, and normal copy scenarios * fix: check all errors in Windows tests, use bytes.Equal for large file comparison * fix: use stable path suffix matching to handle Windows short path names in CI --- crypto/version.go | 41 ++++ crypto/version_test.go | 47 +++++ filemanager/copy.go | 35 ++++ filemanager/copy_other.go | 12 ++ filemanager/copy_windows.go | 332 +++++++++++++++++++++++++++++++ filemanager/copy_windows_test.go | 164 +++++++++++++++ filemanager/session.go | 75 +++++++ filemanager/session_test.go | 103 ++++++++++ 8 files changed, 809 insertions(+) create mode 100644 crypto/version.go create mode 100644 crypto/version_test.go create mode 100644 filemanager/copy.go create mode 100644 filemanager/copy_other.go create mode 100644 filemanager/copy_windows.go create mode 100644 filemanager/copy_windows_test.go create mode 100644 filemanager/session.go create mode 100644 filemanager/session_test.go diff --git a/crypto/version.go b/crypto/version.go new file mode 100644 index 0000000..dbb055a --- /dev/null +++ b/crypto/version.go @@ -0,0 +1,41 @@ +package crypto + +// CipherVersion represents the encryption version used by Chromium browsers. +type CipherVersion string + +const ( + // CipherV10 is Chrome 80+ encryption (AES-GCM on Windows, AES-CBC on macOS/Linux). + CipherV10 CipherVersion = "v10" + + // CipherV20 is Chrome 127+ App-Bound Encryption. + CipherV20 CipherVersion = "v20" + + // CipherDPAPI is pre-Chrome 80 raw DPAPI encryption (no version prefix). + CipherDPAPI CipherVersion = "dpapi" +) + +// DetectVersion identifies the encryption version from a ciphertext prefix. +func DetectVersion(ciphertext []byte) CipherVersion { + if len(ciphertext) < 3 { + return CipherDPAPI + } + prefix := string(ciphertext[:3]) + switch prefix { + case "v10": + return CipherV10 + case "v20": + return CipherV20 + default: + return CipherDPAPI + } +} + +// StripPrefix removes the version prefix (e.g. "v10") from ciphertext. +// Returns the ciphertext unchanged if no known prefix is found. +func StripPrefix(ciphertext []byte) []byte { + ver := DetectVersion(ciphertext) + if ver == CipherV10 || ver == CipherV20 { + return ciphertext[3:] + } + return ciphertext +} diff --git a/crypto/version_test.go b/crypto/version_test.go new file mode 100644 index 0000000..efd7c0c --- /dev/null +++ b/crypto/version_test.go @@ -0,0 +1,47 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectVersion(t *testing.T) { + tests := []struct { + name string + ciphertext []byte + want CipherVersion + }{ + {"v10 prefix", []byte("v10" + "encrypted_data"), CipherV10}, + {"v20 prefix", []byte("v20" + "encrypted_data"), CipherV20}, + {"no prefix (DPAPI)", []byte{0x01, 0x00, 0x00, 0x00}, CipherDPAPI}, + {"short input", []byte{0x01, 0x02}, CipherDPAPI}, + {"empty input", []byte{}, CipherDPAPI}, + {"nil input", nil, CipherDPAPI}, + {"unknown prefix", []byte("xyz_data"), CipherDPAPI}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, DetectVersion(tt.ciphertext)) + }) + } +} + +func TestStripPrefix(t *testing.T) { + tests := []struct { + name string + ciphertext []byte + want []byte + }{ + {"strips v10", []byte("v10PAYLOAD"), []byte("PAYLOAD")}, + {"strips v20", []byte("v20PAYLOAD"), []byte("PAYLOAD")}, + {"keeps DPAPI unchanged", []byte{0x01, 0x00, 0x00}, []byte{0x01, 0x00, 0x00}}, + {"keeps short unchanged", []byte{0x01}, []byte{0x01}}, + {"keeps nil unchanged", nil, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, StripPrefix(tt.ciphertext)) + }) + } +} diff --git a/filemanager/copy.go b/filemanager/copy.go new file mode 100644 index 0000000..cbf2bd0 --- /dev/null +++ b/filemanager/copy.go @@ -0,0 +1,35 @@ +package filemanager + +import ( + "os" + "strings" + + cp "github.com/otiai10/copy" +) + +// copyFile copies a single file from src to dst. +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o600) +} + +// copyDir copies a directory from src to dst, skipping files +// whose path ends with the skip suffix (e.g. "lock"). +func copyDir(src, dst, skip string) error { + opts := cp.Options{Skip: func(info os.FileInfo, src, _ string) (bool, error) { + return strings.HasSuffix(strings.ToLower(src), skip), nil + }} + return cp.Copy(src, dst, opts) +} + +// isFileExists checks if a file (not directory) exists at the given path. +func isFileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/filemanager/copy_other.go b/filemanager/copy_other.go new file mode 100644 index 0000000..8266e0a --- /dev/null +++ b/filemanager/copy_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package filemanager + +import "fmt" + +// copyLocked is not supported on non-Windows platforms and always returns an error. +// File locking is primarily a Windows issue where Chrome holds exclusive +// locks on Cookie files via SQLite WAL mode. +func copyLocked(_, _ string) error { + return fmt.Errorf("locked file copy not supported on this platform") +} diff --git a/filemanager/copy_windows.go b/filemanager/copy_windows.go new file mode 100644 index 0000000..b36ac8d --- /dev/null +++ b/filemanager/copy_windows.go @@ -0,0 +1,332 @@ +//go:build windows + +package filemanager + +import ( + "fmt" + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // systemExtendedHandleInformation is the information class for + // NtQuerySystemInformation that returns SYSTEM_HANDLE_INFORMATION_EX. + // This is the 64-bit safe version (class 64) — UniqueProcessId is ULONG_PTR + // instead of USHORT, avoiding PID truncation on 64-bit Windows. + systemExtendedHandleInformation = 64 + + statusInfoLengthMismatch = 0xC0000004 + + fileMAPRead = 0x0004 + pageReadonly = 0x02 + fileTypeDisk = 0x0001 +) + +// systemHandleTableEntryInfoEx represents SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX. +// This is the extended version returned by SystemExtendedHandleInformation (class 64). +// +// Layout (64-bit Windows): +// +// PVOID Object; // 8 bytes +// ULONG_PTR UniqueProcessId; // 8 bytes +// ULONG_PTR HandleValue; // 8 bytes +// ULONG GrantedAccess; // 4 bytes +// USHORT CreatorBackTraceIndex; // 2 bytes +// USHORT ObjectTypeIndex; // 2 bytes +// ULONG HandleAttributes; // 4 bytes +// ULONG Reserved; // 4 bytes +// Total: 40 bytes on 64-bit +type systemHandleTableEntryInfoEx struct { + Object uintptr + UniqueProcessID uintptr // ULONG_PTR: safe for PID > 65535 + HandleValue uintptr // ULONG_PTR: safe for large handle values + GrantedAccess uint32 + CreatorBackTraceIndex uint16 + ObjectTypeIndex uint16 + HandleAttributes uint32 + Reserved uint32 +} + +var ( + ntdll = windows.NewLazySystemDLL("ntdll.dll") + procNtQuerySystemInformation = ntdll.NewProc("NtQuerySystemInformation") + + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetFileType = kernel32.NewProc("GetFileType") + procGetFinalPathNameByHandleW = kernel32.NewProc("GetFinalPathNameByHandleW") + procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW") + procMapViewOfFile = kernel32.NewProc("MapViewOfFile") + procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile") + procGetFileSizeEx = kernel32.NewProc("GetFileSizeEx") +) + +// copyLocked copies a file that is locked by another process (e.g., Chrome's +// Cookies database with PRAGMA locking_mode=EXCLUSIVE). +// +// Approach: DuplicateHandle + FileMapping +// 1. Enumerate all open file handles via NtQuerySystemInformation(SystemExtendedHandleInformation) +// 2. Find the handle matching the target file path +// 3. Duplicate that handle into our process via DuplicateHandle +// 4. Read file content through memory-mapped I/O (CreateFileMapping + MapViewOfFile) +// 5. Write content to destination +// +// This requires only normal user privileges (no admin needed). +func copyLocked(src, dst string) error { + handle, err := findFileHandle(src) + if err != nil { + return fmt.Errorf("find file handle for %s: %w", src, err) + } + defer windows.CloseHandle(handle) + + data, err := readFileContent(handle) + if err != nil { + return fmt.Errorf("read via file mapping: %w", err) + } + + return os.WriteFile(dst, data, 0o600) +} + +// findFileHandle enumerates all system handles, finds the one matching the +// target file path, and duplicates it into the current process. +func findFileHandle(targetPath string) (windows.Handle, error) { + // Extract a stable suffix for matching that avoids short path name issues + // (e.g., RUNNER~1 vs runneradmin in the username portion). + // We match from AppData onwards, which uniquely identifies each browser: + // Google\Chrome\User Data\Default\Network\Cookies (Chrome) + // Microsoft\Edge\User Data\Default\Network\Cookies (Edge) + targetSuffix := extractStableSuffix(targetPath) + currentProcess := windows.CurrentProcess() + + handles, err := querySystemHandles() + if err != nil { + return 0, err + } + + for _, h := range handles { + pid := uint32(h.UniqueProcessID) + if pid == 0 { + continue + } + + // Open the owning process to duplicate its handle + process, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE, false, pid) + if err != nil { + continue + } + + // Duplicate the handle into our process + var dupHandle windows.Handle + err = windows.DuplicateHandle( + process, + windows.Handle(h.HandleValue), + currentProcess, + &dupHandle, + 0, false, + windows.DUPLICATE_SAME_ACCESS, + ) + windows.CloseHandle(process) + if err != nil { + continue + } + + // Verify it's a disk file (not a pipe, device, etc.) + fileType, _, _ := procGetFileType.Call(uintptr(dupHandle)) + if fileType != fileTypeDisk { + windows.CloseHandle(dupHandle) + continue + } + + // Get the file path and check if it matches our target + name, err := getFinalPathName(dupHandle) + if err != nil { + windows.CloseHandle(dupHandle) + continue + } + + if strings.HasSuffix(strings.ToLower(name), targetSuffix) { + return dupHandle, nil + } + windows.CloseHandle(dupHandle) + } + + return 0, fmt.Errorf("no process has file open: %s", targetPath) +} + +// querySystemHandles calls NtQuerySystemInformation with +// SystemExtendedHandleInformation (class 64) to enumerate all open handles. +func querySystemHandles() ([]systemHandleTableEntryInfoEx, error) { + bufSize := uint32(4 * 1024 * 1024) // start at 4 MB + + for { + buf := make([]byte, bufSize) + var returnLength uint32 + + ret, _, _ := procNtQuerySystemInformation.Call( + systemExtendedHandleInformation, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(bufSize), + uintptr(unsafe.Pointer(&returnLength)), + ) + + if ret == statusInfoLengthMismatch { + bufSize *= 2 + if bufSize > 256*1024*1024 { + return nil, fmt.Errorf("handle info buffer exceeded 256 MB") + } + continue + } + if ret != 0 { + return nil, fmt.Errorf("NtQuerySystemInformation returned 0x%x", ret) + } + + // Parse: first field is NumberOfHandles (ULONG_PTR), then array of entries + // On 64-bit: ULONG_PTR = 8 bytes + numberOfHandles := *(*uintptr)(unsafe.Pointer(&buf[0])) + if numberOfHandles == 0 { + return nil, nil + } + + count := int(numberOfHandles) + // Entries start after NumberOfHandles + Reserved (both ULONG_PTR = 16 bytes total) + const headerSize = unsafe.Sizeof(uintptr(0)) * 2 + entrySize := unsafe.Sizeof(systemHandleTableEntryInfoEx{}) + + // Validate buffer bounds + required := headerSize + uintptr(count)*entrySize + if required > uintptr(len(buf)) { + return nil, fmt.Errorf("buffer too small: need %d, have %d", required, len(buf)) + } + + entries := make([]systemHandleTableEntryInfoEx, count) + for i := 0; i < count; i++ { + src := unsafe.Pointer(uintptr(unsafe.Pointer(&buf[0])) + headerSize + uintptr(i)*entrySize) + entries[i] = *(*systemHandleTableEntryInfoEx)(src) + } + return entries, nil + } +} + +// getFinalPathName returns the normalized file path for a file handle. +func getFinalPathName(handle windows.Handle) (string, error) { + size := 512 + for { + buf := make([]uint16, size) + n, _, err := procGetFinalPathNameByHandleW.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, // FILE_NAME_NORMALIZED + ) + if n == 0 { + return "", fmt.Errorf("GetFinalPathNameByHandle: %w", err) + } + if int(n) > len(buf) { + // Buffer too small, retry with required size + size = int(n) + continue + } + + path := windows.UTF16ToString(buf[:n]) + // Remove \\?\ prefix added by GetFinalPathNameByHandle + path = strings.TrimPrefix(path, `\\?\`) + return path, nil + } +} + +// readFileContent reads file content from a duplicated handle. +// It uses FileMapping first (CreateFileMapping + MapViewOfFile), which reads +// from the OS kernel's file cache — this includes WAL data that Chrome has +// written but not yet checkpointed to the main file. Falls back to ReadFile +// if FileMapping fails. +func readFileContent(handle windows.Handle) ([]byte, error) { + // Get file size + var fileSize int64 + ret, _, sizeErr := procGetFileSizeEx.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&fileSize)), + ) + if ret == 0 { + return nil, fmt.Errorf("GetFileSizeEx: %w", sizeErr) + } + if fileSize == 0 { + return nil, fmt.Errorf("file is empty") + } + + size := int(fileSize) + + // Try FileMapping first — reads from kernel file cache, includes WAL data + if data, err := readViaFileMapping(handle, size); err == nil { + return data, nil + } + + // FileMapping failed, fall back to ReadFile + // Seek to beginning first — the handle's file pointer may be at an arbitrary position + if _, err := windows.Seek(handle, 0, 0); err != nil { + return nil, fmt.Errorf("seek to start: %w", err) + } + data := make([]byte, size) + var bytesRead uint32 + if err := windows.ReadFile(handle, data, &bytesRead, nil); err != nil { + return nil, fmt.Errorf("ReadFile: %w", err) + } + return data[:bytesRead], nil +} + +// readViaFileMapping reads file content using CreateFileMapping + MapViewOfFile. +func readViaFileMapping(handle windows.Handle, size int) ([]byte, error) { + mapping, _, err := procCreateFileMappingW.Call( + uintptr(handle), + 0, pageReadonly, + 0, 0, 0, + ) + if mapping == 0 { + return nil, fmt.Errorf("CreateFileMapping: %w", err) + } + defer windows.CloseHandle(windows.Handle(mapping)) + + viewPtr, _, err := procMapViewOfFile.Call( + mapping, fileMAPRead, + 0, 0, 0, + ) + if viewPtr == 0 { + return nil, fmt.Errorf("MapViewOfFile: %w", err) + } + defer procUnmapViewOfFile.Call(viewPtr) + + // viewPtr is a valid pointer from MapViewOfFile syscall. + // go vet flags this as "possible misuse of unsafe.Pointer" but it's + // correct usage for Windows memory-mapped I/O. + data := make([]byte, size) + copy(data, (*[1 << 30]byte)(unsafe.Pointer(viewPtr))[:size]) //nolint:govet + return data, nil +} + +// extractStableSuffix extracts a path suffix that is stable across short/long +// path name variations. It finds "AppData" in the path and returns everything +// after "AppData\Local\" or "AppData\Roaming\" in lowercase. +// +// Example: +// +// C:\Users\RUNNER~1\AppData\Local\Google\Chrome\...\Cookies +// → google\chrome\...\cookies +// +// For paths without "AppData" (e.g., test temp dirs), it falls back to +// the last 3 path components to provide reasonable matching specificity. +func extractStableSuffix(path string) string { + lower := strings.ToLower(path) + // Try to find AppData\Local\ or AppData\Roaming\ + for _, marker := range []string{`appdata\local\`, `appdata\roaming\`} { + if idx := strings.Index(lower, marker); idx != -1 { + return lower[idx+len(marker):] + } + } + // Fallback: use last 3 components for test paths + parts := strings.Split(lower, string(os.PathSeparator)) + if len(parts) >= 3 { + return strings.Join(parts[len(parts)-3:], string(os.PathSeparator)) + } + return lower +} diff --git a/filemanager/copy_windows_test.go b/filemanager/copy_windows_test.go new file mode 100644 index 0000000..ba65a23 --- /dev/null +++ b/filemanager/copy_windows_test.go @@ -0,0 +1,164 @@ +//go:build windows + +package filemanager + +import ( + "bytes" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopyLocked_ExclusiveLock(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "locked.db") + testData := []byte("this is locked file content") + + require.NoError(t, os.WriteFile(src, testData, 0o644)) + + handle := openExclusive(t, src) + defer syscall.CloseHandle(handle) + + // Normal copy should fail + err := copyFile(src, filepath.Join(dir, "normal_copy.db")) + assert.Error(t, err, "normal copy should fail on exclusively locked file") + + // copyLocked should succeed via DuplicateHandle + FileMapping + lockedDst := filepath.Join(dir, "locked_copy.db") + err = copyLocked(src, lockedDst) + assert.NoError(t, err, "copyLocked should bypass exclusive lock") + + copied, err := os.ReadFile(lockedDst) + require.NoError(t, err) + assert.Equal(t, testData, copied, "copied content should match original") +} + +func TestCopyLocked_WriteThenRead(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "modified.db") + + // Write initial data + require.NoError(t, os.WriteFile(src, []byte("initial"), 0o644)) + + // Open exclusively and write more data through the handle + handle := openExclusive(t, src) + defer syscall.CloseHandle(handle) + + // Seek to end and write additional data + _, seekErr := syscall.Seek(handle, 0, 2) // SEEK_END + require.NoError(t, seekErr) + additional := []byte(" + appended data") + var written uint32 + writeErr := syscall.WriteFile(handle, additional, &written, nil) + require.NoError(t, writeErr) + require.Equal(t, uint32(len(additional)), written) + flushErr := syscall.FlushFileBuffers(handle) + require.NoError(t, flushErr) + + // copyLocked should read the full content including appended data + lockedDst := filepath.Join(dir, "modified_copy.db") + copyErr := copyLocked(src, lockedDst) + assert.NoError(t, copyErr) + + copied, err := os.ReadFile(lockedDst) + require.NoError(t, err) + assert.Equal(t, "initial + appended data", string(copied), + "should read complete content including data written after lock") +} + +func TestCopyLocked_LargeFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "large.db") + + // Create a file similar in size to a real Cookies database (~64KB) + data := make([]byte, 65536) + for i := range data { + data[i] = byte(i % 256) + } + require.NoError(t, os.WriteFile(src, data, 0o644)) + + handle := openExclusive(t, src) + defer syscall.CloseHandle(handle) + + lockedDst := filepath.Join(dir, "large_copy.db") + err := copyLocked(src, lockedDst) + assert.NoError(t, err) + + copied, err := os.ReadFile(lockedDst) + require.NoError(t, err) + assert.Equal(t, len(data), len(copied), "file sizes should match") + assert.True(t, bytes.Equal(data, copied), "file content should match byte-for-byte") +} + +func TestCopyLocked_FileNotFound(t *testing.T) { + err := copyLocked("/nonexistent/file.db", filepath.Join(t.TempDir(), "dst.db")) + assert.Error(t, err) +} + +func TestAcquire_FallbackToLocked(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "cookies.db") + testData := []byte("cookie data") + + require.NoError(t, os.WriteFile(src, testData, 0o644)) + + handle := openExclusive(t, src) + defer syscall.CloseHandle(handle) + + session, err := NewSession() + require.NoError(t, err) + defer session.Cleanup() + + dst := filepath.Join(session.TempDir(), "cookies.db") + err = session.Acquire(src, dst, false) + assert.NoError(t, err, "Acquire should succeed via locked fallback") + + copied, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, testData, copied) +} + +func TestAcquire_NormalCopyWhenNotLocked(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "unlocked.db") + testData := []byte("unlocked data") + + require.NoError(t, os.WriteFile(src, testData, 0o644)) + + // No exclusive lock — normal copy should work without needing copyLocked + session, err := NewSession() + require.NoError(t, err) + defer session.Cleanup() + + dst := filepath.Join(session.TempDir(), "unlocked.db") + err = session.Acquire(src, dst, false) + assert.NoError(t, err) + + copied, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, testData, copied) +} + +// openExclusive opens a file with exclusive lock (dwShareMode=0), +// simulating Chrome's PRAGMA locking_mode=EXCLUSIVE behavior. +func openExclusive(t *testing.T, path string) syscall.Handle { + t.Helper() + srcPtr, err := syscall.UTF16PtrFromString(path) + require.NoError(t, err) + + handle, err := syscall.CreateFile( + srcPtr, + syscall.GENERIC_READ|syscall.GENERIC_WRITE, + 0, // exclusive: no sharing + nil, + syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL, + 0, + ) + require.NoError(t, err) + return handle +} diff --git a/filemanager/session.go b/filemanager/session.go new file mode 100644 index 0000000..e21e3c0 --- /dev/null +++ b/filemanager/session.go @@ -0,0 +1,75 @@ +package filemanager + +import ( + "errors" + "fmt" + "os" + "runtime" +) + +// Session manages temporary files for a single browser extraction run. +// It creates an isolated temp directory and provides methods to copy +// browser files into it. Call Cleanup() when done to remove all temp files. +type Session struct { + tempDir string +} + +// NewSession creates a session with a unique temporary directory. +func NewSession() (*Session, error) { + dir, err := os.MkdirTemp("", "hbd-*") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + return &Session{tempDir: dir}, nil +} + +// TempDir returns the session's temporary directory path. +func (s *Session) TempDir() string { + return s.tempDir +} + +// Acquire copies a browser file (or directory) from src to dst. +// For regular files, it also copies SQLite WAL and SHM companion files +// if they exist. For directories (e.g. LevelDB), it copies the entire +// directory while skipping lock files. +// +// On Windows, if the normal copy fails (e.g. file locked by Chrome), +// it falls back to DuplicateHandle + FileMapping to bypass exclusive locks. +func (s *Session) Acquire(src, dst string, isDir bool) error { + if isDir { + return copyDir(src, dst, "lock") + } + + // Try normal copy first + err := copyFile(src, dst) + if err != nil { + // Only attempt locked-file fallback on Windows where Chrome holds exclusive locks. + // On other platforms, return the original error directly. + if runtime.GOOS != "windows" { + return fmt.Errorf("copy: %w", err) + } + if err2 := copyLocked(src, dst); err2 != nil { + return errors.Join( + fmt.Errorf("copy: %w", err), + fmt.Errorf("locked copy: %w", err2), + ) + } + } + + // Copy SQLite WAL/SHM companion files if present + var walErrs []error + for _, suffix := range []string{"-wal", "-shm"} { + walSrc := src + suffix + if isFileExists(walSrc) { + if err := copyFile(walSrc, dst+suffix); err != nil { + walErrs = append(walErrs, fmt.Errorf("copy %s: %w", suffix, err)) + } + } + } + return errors.Join(walErrs...) +} + +// Cleanup removes the session's temporary directory and all its contents. +func (s *Session) Cleanup() { + os.RemoveAll(s.tempDir) +} diff --git a/filemanager/session_test.go b/filemanager/session_test.go new file mode 100644 index 0000000..7f1f678 --- /dev/null +++ b/filemanager/session_test.go @@ -0,0 +1,103 @@ +package filemanager + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSession(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + defer s.Cleanup() + + assert.DirExists(t, s.TempDir()) + assert.Contains(t, s.TempDir(), "hbd-") +} + +func TestSession_Cleanup(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + + dir := s.TempDir() + assert.DirExists(t, dir) + + s.Cleanup() + assert.NoDirExists(t, dir) +} + +func TestSession_Acquire_File(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + defer s.Cleanup() + + // Create a source file + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "Login Data") + require.NoError(t, os.WriteFile(srcFile, []byte("test data"), 0o644)) + + // Acquire it + dst := filepath.Join(s.TempDir(), "Login Data") + err = s.Acquire(srcFile, dst, false) + assert.NoError(t, err) + + // Verify copy + data, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "test data", string(data)) +} + +func TestSession_Acquire_WAL(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + defer s.Cleanup() + + srcDir := t.TempDir() + srcFile := filepath.Join(srcDir, "Cookies") + require.NoError(t, os.WriteFile(srcFile, []byte("db"), 0o644)) + require.NoError(t, os.WriteFile(srcFile+"-wal", []byte("wal"), 0o644)) + require.NoError(t, os.WriteFile(srcFile+"-shm", []byte("shm"), 0o644)) + + dst := filepath.Join(s.TempDir(), "Cookies") + err = s.Acquire(srcFile, dst, false) + assert.NoError(t, err) + + // Main file copied + assert.FileExists(t, dst) + // WAL and SHM also copied + assert.FileExists(t, dst+"-wal") + assert.FileExists(t, dst+"-shm") +} + +func TestSession_Acquire_Dir(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + defer s.Cleanup() + + // Create a source directory with files + srcDir := filepath.Join(t.TempDir(), "leveldb") + require.NoError(t, os.MkdirAll(srcDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "000001.ldb"), []byte("data"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "LOCK"), []byte(""), 0o644)) + + dst := filepath.Join(s.TempDir(), "leveldb") + err = s.Acquire(srcDir, dst, true) + assert.NoError(t, err) + + // Data file copied + assert.FileExists(t, filepath.Join(dst, "000001.ldb")) + // LOCK file skipped (CopyDir skips "lock" suffix) +} + +func TestSession_Acquire_NotFound(t *testing.T) { + s, err := NewSession() + require.NoError(t, err) + defer s.Cleanup() + + dst := filepath.Join(s.TempDir(), "nope") + err = s.Acquire("/nonexistent/file", dst, false) + assert.Error(t, err) +}