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:
Roger
2026-03-25 23:54:22 +08:00
committed by moonD4rk
parent e86e3e62d6
commit 12436217ae
8 changed files with 809 additions and 0 deletions
+41
View File
@@ -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
}
+47
View File
@@ -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))
})
}
}
+35
View File
@@ -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()
}
+12
View File
@@ -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")
}
+332
View File
@@ -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
}
+164
View File
@@ -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
}
+75
View File
@@ -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)
}
+103
View File
@@ -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)
}