mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user