refactor(browser): split installation and profile abstractions (#603)

* refactor(browser): split installation and profile abstractions

A Chromium installation shares one master key across its profiles, but
modeling each profile as its own Browser re-derived the key per profile.
Browser now represents one installation holding its profiles and derives
the key once; new types.Profile/ExtractResult/CountResult carry per-profile
results.

* style: gofumpt safari_test.go

* test(chromium): rename shadowed loop var to path
This commit is contained in:
Roger
2026-05-31 16:37:23 +08:00
committed by GitHub
parent d5dc81f1c0
commit b901f7dff0
28 changed files with 1359 additions and 1206 deletions
+1 -1
View File
@@ -72,4 +72,4 @@ make payload-clean # rm crypto/*.bin
- `modernc.org/sqlite` pinned at v1.31.1 (v1.32+ requires Go 1.21) - `modernc.org/sqlite` pinned at v1.31.1 (v1.32+ requires Go 1.21)
- `golang.org/x/text` will be removed in refactoring (use 3-byte UTF-8 BOM instead) - `golang.org/x/text` will be removed in refactoring (use 3-byte UTF-8 BOM instead)
- No `pkg/` + `internal/` directory structure — keep it simple - No `pkg/` + `internal/` directory structure — keep it simple
- No root-level library API — CLI calls `browser.PickBrowsers()` directly - No root-level library API — CLI calls `browser.DiscoverBrowsersWithKeys()` directly
+42 -41
View File
@@ -14,15 +14,15 @@ import (
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
// Browser is the interface implemented by every engine package — // Browser is one installation: a single resolved UserDataDir that holds its
// chromium.Browser, firefox.Browser, and safari.Browser. // profiles and, for Chromium, owns the master key shared across them. It is
// implemented by chromium.Browser, firefox.Browser, and safari.Browser.
type Browser interface { type Browser interface {
BrowserName() string BrowserName() string
ProfileName() string
ProfileDir() string
UserDataDir() string UserDataDir() string
Extract(categories []types.Category) (*types.BrowserData, error) Profiles() []types.Profile
CountEntries(categories []types.Category) (map[types.Category]int, error) Extract(categories []types.Category) ([]types.ExtractResult, error)
CountEntries(categories []types.Category) ([]types.CountResult, error)
} }
// PickOptions configures which browsers to pick. // PickOptions configures which browsers to pick.
@@ -32,7 +32,12 @@ type PickOptions struct {
KeychainPassword string // macOS only — see browser_darwin.go KeychainPassword string // macOS only — see browser_darwin.go
} }
// PickBrowsers returns browsers that are fully wired up for Extract: the // browserInjector wires decryption credentials (key retrievers and, on macOS,
// the Keychain password) into a discovered Browser. Its construction is
// platform-specific; see newCredentialInjector in browser_{darwin,linux,windows}.go.
type browserInjector func(Browser)
// DiscoverBrowsersWithKeys returns installations that are fully wired up for Extract: the
// key retriever chain and (on macOS) the Keychain password are already // key retriever chain and (on macOS) the Keychain password are already
// injected, so the caller can call b.Extract directly. This is the entry // injected, so the caller can call b.Extract directly. This is the entry
// point for extraction workflows like `dump`. // point for extraction workflows like `dump`.
@@ -44,36 +49,35 @@ type PickOptions struct {
// //
// When Name is "all", all known browsers are tried. ProfilePath overrides // When Name is "all", all known browsers are tried. ProfilePath overrides
// the default user data directory (only when targeting a specific browser). // the default user data directory (only when targeting a specific browser).
func PickBrowsers(opts PickOptions) ([]Browser, error) { func DiscoverBrowsersWithKeys(opts PickOptions) ([]Browser, error) {
browsers, err := pickFromConfigs(platformBrowsers(), opts) browsers, err := DiscoverBrowsers(opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
inject := newPlatformInjector(opts) inject := newCredentialInjector(opts)
for _, b := range browsers { for _, b := range browsers {
inject(b) inject(b)
} }
return browsers, nil return browsers, nil
} }
// DiscoverBrowsers returns browsers for metadata-only workflows — listing, // DiscoverBrowsers returns installations for metadata-only workflows — listing,
// profile paths, per-category counts. Decryption dependencies are NOT // profile paths, per-category counts. Decryption dependencies are NOT
// injected, so calling b.Extract on the returned browsers will not // injected, so calling b.Extract on the returned browsers will not
// successfully decrypt protected data (passwords, cookies, credit cards). // successfully decrypt protected data (passwords, cookies, credit cards).
// CountEntries, BrowserName, ProfileName, and ProfileDir all work // CountEntries, BrowserName, and Profiles all work correctly without injection.
// correctly without injection.
// //
// Unlike PickBrowsers, DiscoverBrowsers never prompts for the macOS // Unlike DiscoverBrowsersWithKeys, DiscoverBrowsers never prompts for the macOS
// Keychain password, making it the correct choice for `list`-style // Keychain password, making it the correct choice for `list`-style
// commands that have no use for the credential. // commands that have no use for the credential.
func DiscoverBrowsers(opts PickOptions) ([]Browser, error) { func DiscoverBrowsers(opts PickOptions) ([]Browser, error) {
return pickFromConfigs(platformBrowsers(), opts) return pickFromConfigs(platformBrowsers(), opts)
} }
// pickFromConfigs is the testable core of PickBrowsers: it filters the // pickFromConfigs is the testable core of DiscoverBrowsers: it filters the
// platform browser list and discovers installed profiles for each match. // platform browser list and discovers each matching installation (one Browser
// Dependency injection (key retrievers, keychain credentials) is intentionally // per UserDataDir, holding its profiles). Dependency injection (key retrievers,
// NOT done here — see PrepareExtract. // keychain credentials) is intentionally NOT done here.
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) { func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
name := strings.ToLower(opts.Name) name := strings.ToLower(opts.Name)
if name == "" { if name == "" {
@@ -97,28 +101,28 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser
} }
} }
found, err := newBrowsers(cfg) b, err := newBrowser(cfg)
if err != nil { if err != nil {
log.Errorf("browser %s: %v", cfg.Name, err) log.Errorf("browser %s: %v", cfg.Name, err)
continue continue
} }
if len(found) == 0 { if b == nil {
log.Debugf("browser %s not found at %s", cfg.Name, cfg.UserDataDir) log.Debugf("browser %s not found at %s", cfg.Name, cfg.UserDataDir)
continue continue
} }
browsers = append(browsers, found...) browsers = append(browsers, b)
} }
return browsers, nil return browsers, nil
} }
// KeyManager is implemented by engines that accept externally-provided master-key retrievers (Chromium family only). // KeyManager is implemented by installations that accept externally-provided master-key retrievers (Chromium family only).
type KeyManager interface { type KeyManager interface {
SetKeyRetrievers(keyretriever.Retrievers) SetKeyRetrievers(keyretriever.Retrievers)
ExportKeys() (keyretriever.MasterKeys, error) ExportKeys() (keyretriever.MasterKeys, error)
} }
// KeychainPasswordReceiver is implemented by engines that need the macOS login password (Safari only). // KeychainPasswordReceiver is implemented by installations that need the macOS login password (Safari only).
type KeychainPasswordReceiver interface { type KeychainPasswordReceiver interface {
SetKeychainPassword(string) SetKeychainPassword(string)
} }
@@ -151,42 +155,39 @@ func resolveGlobs(configs []types.BrowserConfig) []types.BrowserConfig {
return out return out
} }
// newBrowsers dispatches to the correct engine based on BrowserKind // newBrowser dispatches to the correct engine based on BrowserKind and returns
// and converts engine-specific types to the Browser interface. // one installation, or a nil Browser when no profile was found.
func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) { func newBrowser(cfg types.BrowserConfig) (Browser, error) {
switch cfg.Kind { switch cfg.Kind {
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera: case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
found, err := chromium.NewBrowsers(cfg) b, err := chromium.NewBrowser(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]Browser, len(found)) if b == nil {
for i, b := range found { return nil, nil
result[i] = b
} }
return result, nil return b, nil
case types.Firefox: case types.Firefox:
found, err := firefox.NewBrowsers(cfg) b, err := firefox.NewBrowser(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]Browser, len(found)) if b == nil {
for i, b := range found { return nil, nil
result[i] = b
} }
return result, nil return b, nil
case types.Safari: case types.Safari:
found, err := safari.NewBrowsers(cfg) b, err := safari.NewBrowser(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]Browser, len(found)) if b == nil {
for i, b := range found { return nil, nil
result[i] = b
} }
return result, nil return b, nil
default: default:
return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind) return nil, fmt.Errorf("unknown browser kind: %d", cfg.Kind)
+2 -2
View File
@@ -155,9 +155,9 @@ func resolveKeychainPassword(flagPassword string) string {
return password return password
} }
// newPlatformInjector lazily wires retrievers (and the macOS keychain password) into each Browser; // newCredentialInjector lazily wires retrievers (and the macOS keychain password) into each Browser;
// `-b firefox` never triggers a keychain prompt because lazy resolution skips browsers that need neither. // `-b firefox` never triggers a keychain prompt because lazy resolution skips browsers that need neither.
func newPlatformInjector(opts PickOptions) func(Browser) { func newCredentialInjector(opts PickOptions) browserInjector {
var ( var (
password string password string
retrievers keyretriever.Retrievers retrievers keyretriever.Retrievers
+2 -2
View File
@@ -67,12 +67,12 @@ func platformBrowsers() []types.BrowserConfig {
} }
} }
// newPlatformInjector returns a closure that wires the Linux Chromium master-key retrievers into // newCredentialInjector returns a closure that wires the Linux Chromium master-key retrievers into
// each Browser. Linux has two tiers: V10 uses the "peanuts" hardcoded password (kV10Key); V11 // each Browser. Linux has two tiers: V10 uses the "peanuts" hardcoded password (kV10Key); V11
// uses the D-Bus Secret Service keyring (kV11Key). V20 is nil — App-Bound Encryption is Windows- // uses the D-Bus Secret Service keyring (kV11Key). V20 is nil — App-Bound Encryption is Windows-
// only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts // only. Both V10 and V11 run independently so a profile carrying mixed cipher prefixes decrypts
// both tiers. // both tiers.
func newPlatformInjector(_ PickOptions) func(Browser) { func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers() retrievers := keyretriever.DefaultRetrievers()
return func(b Browser) { return func(b Browser) {
if km, ok := b.(KeyManager); ok { if km, ok := b.(KeyManager); ok {
+20 -16
View File
@@ -341,7 +341,7 @@ func TestResolveGlobs(t *testing.T) {
} }
} }
func TestNewBrowsersDispatch(t *testing.T) { func TestNewBrowserDispatch(t *testing.T) {
chromiumDir := t.TempDir() chromiumDir := t.TempDir()
mkFile(t, chromiumDir, "Default", "Preferences") mkFile(t, chromiumDir, "Default", "Preferences")
mkFile(t, chromiumDir, "Default", "History") mkFile(t, chromiumDir, "Default", "History")
@@ -357,7 +357,7 @@ func TestNewBrowsersDispatch(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cfg types.BrowserConfig cfg types.BrowserConfig
wantLen int wantNil bool
wantName string wantName string
wantProfile string wantProfile string
wantErr string wantErr string
@@ -365,21 +365,18 @@ func TestNewBrowsersDispatch(t *testing.T) {
{ {
name: "chromium dispatch", name: "chromium dispatch",
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromiumDir}, cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromiumDir},
wantLen: 1,
wantName: "Chrome", wantName: "Chrome",
wantProfile: "Default", wantProfile: "Default",
}, },
{ {
name: "firefox dispatch", name: "firefox dispatch",
cfg: types.BrowserConfig{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir}, cfg: types.BrowserConfig{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir},
wantLen: 1,
wantName: "Firefox", wantName: "Firefox",
wantProfile: "abc.default", wantProfile: "abc.default",
}, },
{ {
name: "safari dispatch", name: "safari dispatch",
cfg: types.BrowserConfig{Key: "safari", Name: "Safari", Kind: types.Safari, UserDataDir: safariDir}, cfg: types.BrowserConfig{Key: "safari", Name: "Safari", Kind: types.Safari, UserDataDir: safariDir},
wantLen: 1,
wantName: "Safari", wantName: "Safari",
wantProfile: "default", wantProfile: "default",
}, },
@@ -389,38 +386,45 @@ func TestNewBrowsersDispatch(t *testing.T) {
wantErr: "unknown browser kind", wantErr: "unknown browser kind",
}, },
{ {
name: "empty dir returns empty", name: "empty dir returns nil",
cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: emptyDir}, cfg: types.BrowserConfig{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: emptyDir},
wantNil: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
found, err := newBrowsers(tt.cfg) b, err := newBrowser(tt.cfg)
if tt.wantErr != "" { if tt.wantErr != "" {
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr) assert.Contains(t, err.Error(), tt.wantErr)
return return
} }
require.NoError(t, err) require.NoError(t, err)
require.Len(t, found, tt.wantLen) if tt.wantNil {
if tt.wantLen > 0 { assert.Nil(t, b)
assert.Equal(t, tt.wantName, found[0].BrowserName()) return
assert.Equal(t, tt.wantProfile, found[0].ProfileName())
} }
require.NotNil(t, b)
assert.Equal(t, tt.wantName, b.BrowserName())
profiles := b.Profiles()
require.NotEmpty(t, profiles)
assert.Equal(t, tt.wantProfile, profiles[0].Name)
}) })
} }
} }
// assertBrowsers verifies browser names and profiles match expectations (order-independent). // assertBrowsers flattens installations into (browser, profile) pairs and
// verifies they match expectations (order-independent).
func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) { func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) {
t.Helper() t.Helper()
assert.Len(t, browsers, len(wantNames))
var gotNames, gotProfiles []string var gotNames, gotProfiles []string
for _, b := range browsers { for _, b := range browsers {
gotNames = append(gotNames, b.BrowserName()) for _, p := range b.Profiles() {
gotProfiles = append(gotProfiles, b.ProfileName()) gotNames = append(gotNames, b.BrowserName())
gotProfiles = append(gotProfiles, p.Name)
}
} }
sort.Strings(gotNames) sort.Strings(gotNames)
sort.Strings(gotProfiles) sort.Strings(gotProfiles)
+2 -2
View File
@@ -125,11 +125,11 @@ func platformBrowsers() []types.BrowserConfig {
} }
} }
// newPlatformInjector returns a closure that wires the Windows v10 (DPAPI) and v20 (ABE) Chromium // newCredentialInjector returns a closure that wires the Windows v10 (DPAPI) and v20 (ABE) Chromium
// master-key retrievers into each Browser. Per issue #578 the two tiers are orthogonal — a single // master-key retrievers into each Browser. Per issue #578 the two tiers are orthogonal — a single
// Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both // Chrome profile upgraded from pre-127 carries v20 cookies alongside v10 passwords — so both
// retrievers run independently rather than as a first-success chain. // retrievers run independently rather than as a first-success chain.
func newPlatformInjector(_ PickOptions) func(Browser) { func newCredentialInjector(_ PickOptions) browserInjector {
retrievers := keyretriever.DefaultRetrievers() retrievers := keyretriever.DefaultRetrievers()
return func(b Browser) { return func(b Browser) {
if km, ok := b.(KeyManager); ok { if km, ok := b.(KeyManager); ok {
+83 -209
View File
@@ -13,66 +13,89 @@ import (
"github.com/moond4rk/hackbrowserdata/utils/fileutil" "github.com/moond4rk/hackbrowserdata/utils/fileutil"
) )
// Browser represents a single Chromium profile ready for extraction. // Browser is one Chromium installation: a single UserDataDir holding profiles
// that share a master key. The key is derived once and reused across profiles.
type Browser struct { type Browser struct {
cfg types.BrowserConfig cfg types.BrowserConfig
profileDir string // absolute path to profile directory retrievers keyretriever.Retrievers
retrievers keyretriever.Retrievers // per-tier key sources (V10 / V11 / V20; unused tiers nil) profiles []*profile
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
extractors map[types.Category]categoryExtractor // Category → custom extract function override keysOnce sync.Once
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path keys keyretriever.MasterKeys
} }
// NewBrowsers discovers Chromium profiles under cfg.UserDataDir and returns // NewBrowser discovers the Chromium profiles under cfg.UserDataDir and returns
// one Browser per profile. Call SetKeyRetrievers on each returned browser before // the installation, or nil if no profile with resolvable sources exists. Call
// Extract to enable decryption of sensitive data (passwords, cookies, etc.). // SetKeyRetrievers before Extract to enable decryption of sensitive data.
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
sources := sourcesForKind(cfg.Kind) sources := sourcesForKind(cfg.Kind)
extractors := extractorsForKind(cfg.Kind) extractors := extractorsForKind(cfg.Kind)
profileDirs := discoverProfiles(cfg.UserDataDir, sources) var profiles []*profile
if len(profileDirs) == 0 { for _, profileDir := range discoverProfiles(cfg.UserDataDir, sources) {
return nil, nil
}
var browsers []*Browser
for _, profileDir := range profileDirs {
sourcePaths := resolveSourcePaths(sources, profileDir) sourcePaths := resolveSourcePaths(sources, profileDir)
if len(sourcePaths) == 0 { if len(sourcePaths) == 0 {
continue continue
} }
browsers = append(browsers, &Browser{ profiles = append(profiles, &profile{
cfg: cfg,
profileDir: profileDir, profileDir: profileDir,
sources: sources, browserName: cfg.Name,
kind: cfg.Kind,
extractors: extractors, extractors: extractors,
sourcePaths: sourcePaths, sourcePaths: sourcePaths,
}) })
} }
return browsers, nil if len(profiles) == 0 {
return nil, nil
}
return &Browser{cfg: cfg, profiles: profiles}, nil
} }
// SetKeyRetrievers wires the per-tier master-key retrievers (V10/V11/V20) used by Extract; unused tiers stay nil. // SetKeyRetrievers wires the per-tier master-key retrievers (V10/V11/V20) used by
func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) { // Extract; unused tiers stay nil.
b.retrievers = r func (b *Browser) SetKeyRetrievers(r keyretriever.Retrievers) { b.retrievers = r }
}
func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" { // Profiles returns the identity of every profile in this installation.
return "" func (b *Browser) Profiles() []types.Profile {
out := make([]types.Profile, 0, len(b.profiles))
for _, p := range b.profiles {
out = append(out, types.Profile{Name: p.name(), Dir: p.profileDir})
} }
return filepath.Base(b.profileDir) return out
} }
// ExportKeys derives this profile's master keys without performing extraction. // Extract derives the installation's master key once, then extracts every profile.
// Returns whatever tiers succeeded plus a joined error describing any failed func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) {
// tiers; callers preserve partial results because a Chrome 127+ profile mixes keys := b.masterKeys()
results := make([]types.ExtractResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.ExtractResult{
Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Data: p.extract(keys, categories),
})
}
return results, nil
}
// CountEntries counts entries per category for every profile without decryption.
func (b *Browser) CountEntries(categories []types.Category) ([]types.CountResult, error) {
results := make([]types.CountResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.CountResult{
Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Counts: p.count(categories),
})
}
return results, nil
}
// ExportKeys derives the installation's master keys without extraction. Returns
// whatever tiers succeeded plus a joined error describing any failed tiers;
// callers preserve partial results because a Chrome 127+ installation mixes
// v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key. // v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key.
// Used by cross-host workflows where keys are produced on one host and consumed
// on another.
func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) { func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) {
session, err := filemanager.NewSession() session, err := filemanager.NewSession()
if err != nil { if err != nil {
@@ -83,25 +106,34 @@ func (b *Browser) ExportKeys() (keyretriever.MasterKeys, error) {
return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session)) return keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session))
} }
// buildHints discovers Local State (acquiring it into session.TempDir so Windows DPAPI/ABE retrievers can // masterKeys derives the installation's keys exactly once and caches them.
// read it from a path the process owns) and assembles per-tier retriever hints. Shared by Extract and // Because derivation happens a single time per installation, a failure is warned
// ExportKeys so the two stay in lockstep. Multi-profile layout: Local State lives in the parent of // exactly once — no cross-profile dedup state is needed.
// profileDir. Flat layout (Opera): Local State sits alongside data files inside profileDir. func (b *Browser) masterKeys() keyretriever.MasterKeys {
func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints { b.keysOnce.Do(func() {
label := b.BrowserName() + "/" + b.ProfileName() keys, err := b.ExportKeys()
var localStateDst string if err != nil {
for _, dir := range []string{filepath.Dir(b.profileDir), b.profileDir} { log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
candidate := filepath.Join(dir, "Local State")
if !fileutil.FileExists(candidate) {
continue
} }
b.keys = keys
})
return b.keys
}
// buildHints acquires Local State (into session.TempDir so Windows DPAPI/ABE
// retrievers can read it from a path the process owns) and assembles per-tier
// retriever hints. Local State lives at the installation root (cfg.UserDataDir)
// in both the multi-profile and flat (Opera) layouts.
func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints {
var localStateDst string
candidate := filepath.Join(b.cfg.UserDataDir, "Local State")
if fileutil.FileExists(candidate) {
dst := filepath.Join(session.TempDir(), "Local State") dst := filepath.Join(session.TempDir(), "Local State")
if err := session.Acquire(candidate, dst, false); err != nil { if err := session.Acquire(candidate, dst, false); err != nil {
log.Debugf("acquire Local State for %s: %v", label, err) log.Debugf("acquire Local State for %s: %v", b.BrowserName(), err)
break } else {
localStateDst = dst
} }
localStateDst = dst
break
} }
abeKey := "" abeKey := ""
@@ -115,164 +147,6 @@ func (b *Browser) buildHints(session *filemanager.Session) keyretriever.Hints {
} }
} }
// Extract copies browser files to a temp directory, retrieves the master key,
// and extracts data for the requested categories.
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
session, err := filemanager.NewSession()
if err != nil {
return nil, err
}
defer session.Cleanup()
tempPaths := b.acquireFiles(session, categories)
keys := b.getMasterKeys(session)
data := &types.BrowserData{}
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
b.extractCategory(data, cat, keys, path)
}
return data, nil
}
// CountEntries copies browser files to a temp directory and counts entries
// per category without decryption. Much faster than Extract for display-only
// use cases like "list --detail".
func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]int, error) {
session, err := filemanager.NewSession()
if err != nil {
return nil, err
}
defer session.Cleanup()
tempPaths := b.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = b.countCategory(cat, path)
}
return counts, nil
}
// countCategory calls the appropriate count function for a category.
func (b *Browser) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(path)
case types.Cookie:
count, err = countCookies(path)
case types.History:
count, err = countHistories(path)
case types.Download:
count, err = countDownloads(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.CreditCard:
if b.cfg.Kind == types.ChromiumYandex {
count, err = countYandexCreditCards(path)
} else {
count, err = countCreditCards(path)
}
case types.Extension:
if b.cfg.Kind == types.ChromiumOpera {
count, err = countOperaExtensions(path)
} else {
count, err = countExtensions(path)
}
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.SessionStorage:
count, err = countSessionStorage(path)
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
return count
}
// acquireFiles copies source files to the session temp directory.
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := b.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
// warnedMasterKeyFailure dedupes "master key retrieval" WARN per installation (BrowserName + UserDataDir);
// profiles share one Safe Storage entry, but glob-expanded configs may yield multiple installations of the same browser.
var warnedMasterKeyFailure sync.Map
// getMasterKeys retrieves master keys for all configured cipher tiers.
func (b *Browser) getMasterKeys(session *filemanager.Session) keyretriever.MasterKeys {
keys, err := keyretriever.NewMasterKeys(b.retrievers, b.buildHints(session))
if err != nil {
installKey := b.BrowserName() + "|" + b.cfg.UserDataDir
if _, already := warnedMasterKeyFailure.LoadOrStore(installKey, struct{}{}); !already {
log.Warnf("%s: master key retrieval: %v", b.BrowserName(), err)
} else {
log.Debugf("%s/%s: master key retrieval: %v", b.BrowserName(), b.ProfileName(), err)
}
}
return keys
}
// extractCategory calls the appropriate extract function for a category.
// If a custom extractor is registered for this category (via extractorsForKind),
// it is used instead of the default switch logic.
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, keys keyretriever.MasterKeys, path string) {
if ext, ok := b.extractors[cat]; ok {
if err := ext.extract(keys, path, data); err != nil {
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
return
}
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(keys, path)
case types.Cookie:
data.Cookies, err = extractCookies(keys, path)
case types.History:
data.Histories, err = extractHistories(path)
case types.Download:
data.Downloads, err = extractDownloads(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.CreditCard:
data.CreditCards, err = extractCreditCards(keys, path)
case types.Extension:
data.Extensions, err = extractExtensions(path)
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.SessionStorage:
data.SessionStorage, err = extractSessionStorage(path)
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
}
// discoverProfiles lists subdirectories of userDataDir that are valid // discoverProfiles lists subdirectories of userDataDir that are valid
// Chromium profile directories. A directory is considered a profile if it // Chromium profile directories. A directory is considered a profile if it
// contains a "Preferences" file, which Chromium creates for every profile. // contains a "Preferences" file, which Chromium creates for every profile.
+49 -166
View File
@@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever" "github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
@@ -220,19 +219,20 @@ func TestNewBrowsers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := types.BrowserConfig{Name: "Test", Kind: tt.kind, UserDataDir: tt.dir} cfg := types.BrowserConfig{Name: "Test", Kind: tt.kind, UserDataDir: tt.dir}
browsers, err := NewBrowsers(cfg) b, err := NewBrowser(cfg)
require.NoError(t, err) require.NoError(t, err)
if len(tt.wantProfiles) == 0 { if len(tt.wantProfiles) == 0 {
assert.Empty(t, browsers) assert.Nil(t, b)
return return
} }
require.Len(t, browsers, len(tt.wantProfiles)) require.NotNil(t, b)
require.Len(t, b.profiles, len(tt.wantProfiles))
nameMap := browsersByProfile(browsers) nameMap := profilesByName(b)
assertProfiles(t, nameMap, tt.wantProfiles, tt.skipProfiles) assertProfiles(t, nameMap, tt.wantProfiles, tt.skipProfiles)
assertCategories(t, nameMap, tt.wantCats) assertCategories(t, nameMap, tt.wantCats)
assertDirCategories(t, browsers, tt.wantDirs) assertDirCategories(t, b.profiles, tt.wantDirs)
}) })
} }
} }
@@ -241,15 +241,15 @@ func TestNewBrowsers(t *testing.T) {
// Test helpers // Test helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func browsersByProfile(browsers []*Browser) map[string]*Browser { func profilesByName(b *Browser) map[string]*profile {
m := make(map[string]*Browser, len(browsers)) m := make(map[string]*profile, len(b.profiles))
for _, b := range browsers { for _, p := range b.profiles {
m[filepath.Base(b.profileDir)] = b m[filepath.Base(p.profileDir)] = p
} }
return m return m
} }
func assertProfiles(t *testing.T, nameMap map[string]*Browser, want, skip []string) { func assertProfiles(t *testing.T, nameMap map[string]*profile, want, skip []string) {
t.Helper() t.Helper()
for _, w := range want { for _, w := range want {
assert.Contains(t, nameMap, w, "should find profile %s", w) assert.Contains(t, nameMap, w, "should find profile %s", w)
@@ -259,17 +259,17 @@ func assertProfiles(t *testing.T, nameMap map[string]*Browser, want, skip []stri
} }
} }
func assertCategories(t *testing.T, nameMap map[string]*Browser, wantCats map[string][]string) { func assertCategories(t *testing.T, nameMap map[string]*profile, wantCats map[string][]string) {
t.Helper() t.Helper()
for profileName, wantFiles := range wantCats { for profileName, wantFiles := range wantCats {
b, ok := nameMap[profileName] p, ok := nameMap[profileName]
if !ok { if !ok {
t.Errorf("profile %s not found", profileName) t.Errorf("profile %s not found", profileName)
continue continue
} }
for _, wantFile := range wantFiles { for _, wantFile := range wantFiles {
found := false found := false
for _, rp := range b.sourcePaths { for _, rp := range p.sourcePaths {
if filepath.Base(rp.absPath) == wantFile { if filepath.Base(rp.absPath) == wantFile {
found = true found = true
break break
@@ -280,11 +280,11 @@ func assertCategories(t *testing.T, nameMap map[string]*Browser, wantCats map[st
} }
} }
func assertDirCategories(t *testing.T, browsers []*Browser, cats []types.Category) { func assertDirCategories(t *testing.T, profiles []*profile, cats []types.Category) {
t.Helper() t.Helper()
for _, cat := range cats { for _, cat := range cats {
for _, b := range browsers { for _, p := range profiles {
if rp, ok := b.sourcePaths[cat]; ok { if rp, ok := p.sourcePaths[cat]; ok {
assert.True(t, rp.isDir, "%s should be isDir=true", cat) assert.True(t, rp.isDir, "%s should be isDir=true", cat)
} }
} }
@@ -345,74 +345,6 @@ func TestExtractorsForKind(t *testing.T) {
assert.Contains(t, operaExt, types.Extension) assert.Contains(t, operaExt, types.Extension)
} }
// TestExtractCategory_CustomExtractor verifies that extractCategory dispatches
// through a registered extractor instead of the default switch logic.
func TestExtractCategory_CustomExtractor(t *testing.T) {
// Create a Browser with a custom extractor that records it was called
called := false
testExtractor := extensionExtractor{
fn: func(path string) ([]types.ExtensionEntry, error) {
called = true
return []types.ExtensionEntry{{Name: "custom", ID: "test-id"}}, nil
},
}
b := &Browser{
extractors: map[types.Category]categoryExtractor{
types.Extension: testExtractor,
},
}
data := &types.BrowserData{}
b.extractCategory(data, types.Extension, keyretriever.MasterKeys{}, "unused-path")
assert.True(t, called, "custom extractor should be called")
require.Len(t, data.Extensions, 1)
assert.Equal(t, "custom", data.Extensions[0].Name)
}
// TestExtractCategory_DefaultFallback verifies that extractCategory uses
// the default switch when no extractor is registered.
func TestExtractCategory_DefaultFallback(t *testing.T) {
path := createTestDB(t, "History", urlsSchema,
insertURL("https://example.com", "Example", 3, 13350000000000000),
)
b := &Browser{
extractors: nil, // no custom extractors
}
data := &types.BrowserData{}
b.extractCategory(data, types.History, keyretriever.MasterKeys{}, path)
require.Len(t, data.Histories, 1)
assert.Equal(t, "Example", data.Histories[0].Title)
}
// ---------------------------------------------------------------------------
// acquireFiles
// ---------------------------------------------------------------------------
func TestAcquireFiles(t *testing.T) {
profileDir := filepath.Join(fixture.chrome, "Default")
resolved := resolveSourcePaths(chromiumSources, profileDir)
b := &Browser{profileDir: profileDir, sources: chromiumSources, sourcePaths: resolved}
session, err := filemanager.NewSession()
require.NoError(t, err)
defer session.Cleanup()
cats := []types.Category{types.History, types.Cookie, types.Bookmark}
paths := b.acquireFiles(session, cats)
assert.Len(t, paths, len(cats))
for _, p := range paths {
_, err := os.Stat(p)
require.NoError(t, err, "acquired file should exist")
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Local State path validation // Local State path validation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -428,12 +360,12 @@ func TestLocalStatePath(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
browsers, err := NewBrowsers(types.BrowserConfig{Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir}) b, err := NewBrowser(types.BrowserConfig{Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir})
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, browsers) require.NotNil(t, b)
for _, b := range browsers { for _, p := range b.profiles {
localState := filepath.Join(filepath.Dir(b.profileDir), "Local State") localState := filepath.Join(filepath.Dir(p.profileDir), "Local State")
if tt.want { if tt.want {
assert.FileExists(t, localState) assert.FileExists(t, localState)
} }
@@ -503,22 +435,17 @@ func TestGetMasterKeys(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, KeychainLabel: tt.keychainLabel, Name: "Test", Kind: types.Chromium, UserDataDir: tt.dir, KeychainLabel: tt.keychainLabel,
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, browsers) require.NotNil(t, b)
b := browsers[0]
if tt.retriever != nil { if tt.retriever != nil {
b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever})
} }
session, err := filemanager.NewSession() keys := b.masterKeys()
require.NoError(t, err)
defer session.Cleanup()
keys := b.getMasterKeys(session)
assert.Equal(t, tt.wantV10, keys.V10) assert.Equal(t, tt.wantV10, keys.V10)
assert.Nil(t, keys.V11, "V11 stays nil when no v11 retriever is wired") assert.Nil(t, keys.V11, "V11 stays nil when no v11 retriever is wired")
assert.Nil(t, keys.V20, "V20 stays nil when no v20 retriever is wired") assert.Nil(t, keys.V20, "V20 stays nil when no v20 retriever is wired")
@@ -550,20 +477,15 @@ func TestGetMasterKeys_AllTiersInvoked(t *testing.T) {
v11mock := &mockRetriever{key: []byte("fake-v11-key")} v11mock := &mockRetriever{key: []byte("fake-v11-key")}
v20mock := &mockRetriever{key: []byte("fake-v20-key")} v20mock := &mockRetriever{key: []byte("fake-v20-key")}
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, KeychainLabel: "Chrome", Name: "Test", Kind: types.Chromium, UserDataDir: fixture.chrome, KeychainLabel: "Chrome",
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, browsers) require.NotNil(t, b)
b := browsers[0]
b.SetKeyRetrievers(keyretriever.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock}) b.SetKeyRetrievers(keyretriever.Retrievers{V10: v10mock, V11: v11mock, V20: v20mock})
session, err := filemanager.NewSession() keys := b.masterKeys()
require.NoError(t, err)
defer session.Cleanup()
keys := b.getMasterKeys(session)
assert.Equal(t, []byte("fake-v10-key"), keys.V10, "V10 slot must be populated") assert.Equal(t, []byte("fake-v10-key"), keys.V10, "V10 slot must be populated")
assert.Equal(t, []byte("fake-v11-key"), keys.V11, "V11 slot must be populated") assert.Equal(t, []byte("fake-v11-key"), keys.V11, "V11 slot must be populated")
assert.Equal(t, []byte("fake-v20-key"), keys.V20, "V20 slot must be populated") assert.Equal(t, []byte("fake-v20-key"), keys.V20, "V20 slot must be populated")
@@ -592,21 +514,16 @@ func TestGetMasterKeys_WindowsABEThreading(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mock := &mockRetriever{key: []byte("k")} mock := &mockRetriever{key: []byte("k")}
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Key: tt.key, Name: "Test", Kind: types.Chromium, Key: tt.key, Name: "Test", Kind: types.Chromium,
UserDataDir: fixture.chrome, WindowsABE: tt.windowsABE, UserDataDir: fixture.chrome, WindowsABE: tt.windowsABE,
}) })
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, browsers) require.NotNil(t, b)
b := browsers[0]
b.SetKeyRetrievers(keyretriever.Retrievers{V20: mock}) b.SetKeyRetrievers(keyretriever.Retrievers{V20: mock})
session, err := filemanager.NewSession() b.masterKeys()
require.NoError(t, err)
defer session.Cleanup()
b.getMasterKeys(session)
assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey) assert.Equal(t, tt.wantABEKey, mock.hints.WindowsABEKey)
}) })
} }
@@ -638,22 +555,23 @@ func TestExtract(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: dir, KeychainLabel: "Chrome", Name: "Test", Kind: types.Chromium, UserDataDir: dir, KeychainLabel: "Chrome",
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 1) require.NotNil(t, b)
if tt.retriever != nil { if tt.retriever != nil {
browsers[0].SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever}) b.SetKeyRetrievers(keyretriever.Retrievers{V10: tt.retriever})
} }
result, err := browsers[0].Extract([]types.Category{types.History}) results, err := b.Extract([]types.Category{types.History})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.Len(t, results, 1)
require.Len(t, result.Histories, 3) require.NotNil(t, results[0].Data)
require.Len(t, results[0].Data.Histories, 3)
// setupHistoryDB: Example(200) > GitHub(100) > Go Dev(50) // setupHistoryDB: Example(200) > GitHub(100) > Go Dev(50)
assert.Equal(t, "Example", result.Histories[0].Title) assert.Equal(t, "Example", results[0].Data.Histories[0].Title)
if tt.wantRetriever { if tt.wantRetriever {
mock, ok := tt.retriever.(*mockRetriever) mock, ok := tt.retriever.(*mockRetriever)
@@ -673,21 +591,22 @@ func TestCountEntries(t *testing.T) {
mkFile(dir, "Default", "Preferences") mkFile(dir, "Default", "Preferences")
installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History") installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History")
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: dir, Name: "Test", Kind: types.Chromium, UserDataDir: dir,
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 1) require.NotNil(t, b)
// No retriever set — CountEntries should still work (no decryption needed). // No retriever set — CountEntries should still work (no decryption needed).
counts, err := browsers[0].CountEntries([]types.Category{types.History, types.Download}) results, err := b.CountEntries([]types.Category{types.History, types.Download})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, 3, counts[types.History]) assert.Equal(t, 3, results[0].Counts[types.History])
// Download uses a different table in the same file; since we only // Download uses a different table in the same file; since we only
// created the urls table (not downloads), the count query will fail // created the urls table (not downloads), the count query will fail
// gracefully and return 0. // gracefully and return 0.
assert.Equal(t, 0, counts[types.Download]) assert.Equal(t, 0, results[0].Counts[types.Download])
} }
func TestCountEntries_NoRetrieverNeeded(t *testing.T) { func TestCountEntries_NoRetrieverNeeded(t *testing.T) {
@@ -696,53 +615,17 @@ func TestCountEntries_NoRetrieverNeeded(t *testing.T) {
// Login Data normally needs master key to extract, but CountEntries skips decryption. // Login Data normally needs master key to extract, but CountEntries skips decryption.
installFile(t, filepath.Join(dir, "Default"), setupLoginDB(t), "Login Data") installFile(t, filepath.Join(dir, "Default"), setupLoginDB(t), "Login Data")
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Test", Kind: types.Chromium, UserDataDir: dir, Name: "Test", Kind: types.Chromium, UserDataDir: dir,
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 1) require.NotNil(t, b)
// No retriever set — CountEntries succeeds without master key. // No retriever set — CountEntries succeeds without master key.
counts, err := browsers[0].CountEntries([]types.Category{types.Password}) results, err := b.CountEntries([]types.Category{types.Password})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 2, counts[types.Password]) require.Len(t, results, 1)
} assert.Equal(t, 2, results[0].Counts[types.Password])
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := setupHistoryDB(t)
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
assert.Equal(t, 3, b.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := setupCookieDB(t)
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
})
t.Run("Bookmark", func(t *testing.T) {
path := setupBookmarkJSON(t)
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
assert.Equal(t, 3, b.countCategory(types.Bookmark, path))
})
t.Run("Extension_Opera", func(t *testing.T) {
path := createTestJSON(t, "Secure Preferences", `{
"extensions": {
"opsettings": {
"ext1": {"location": 1, "manifest": {"name": "Ext", "version": "1.0"}}
}
}
}`)
b := &Browser{cfg: types.BrowserConfig{Kind: types.ChromiumOpera}}
assert.Equal(t, 1, b.countCategory(types.Extension, path))
})
t.Run("FileNotFound", func(t *testing.T) {
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
assert.Equal(t, 0, b.countCategory(types.History, "/nonexistent/path"))
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+164
View File
@@ -0,0 +1,164 @@
package chromium
import (
"path/filepath"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
)
// profile is one Chromium profile under an installation — the leaf extraction
// unit. It reads its own source files but reuses the installation's master keys.
type profile struct {
profileDir string
browserName string
kind types.BrowserKind
extractors map[types.Category]categoryExtractor
sourcePaths map[types.Category]resolvedPath
}
func (p *profile) name() string {
if p.profileDir == "" {
return ""
}
return filepath.Base(p.profileDir)
}
func (p *profile) label() string { return p.browserName + "/" + p.name() }
// extract copies the profile's source files to a temp directory and extracts the
// requested categories, decrypting with the installation's master keys.
func (p *profile) extract(keys keyretriever.MasterKeys, categories []types.Category) *types.BrowserData {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return &types.BrowserData{}
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
data := &types.BrowserData{}
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
p.extractCategory(data, cat, keys, path)
}
return data
}
// count counts entries per category without decryption.
func (p *profile) count(categories []types.Category) map[types.Category]int {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return nil
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = p.countCategory(cat, path)
}
return counts
}
// acquireFiles copies source files to the session temp directory.
func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := p.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
// extractCategory calls the appropriate extract function for a category. A custom
// extractor (registered via extractorsForKind) takes precedence over the switch.
func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, keys keyretriever.MasterKeys, path string) {
if ext, ok := p.extractors[cat]; ok {
if err := ext.extract(keys, path, data); err != nil {
log.Debugf("extract %s for %s: %v", cat, p.label(), err)
}
return
}
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(keys, path)
case types.Cookie:
data.Cookies, err = extractCookies(keys, path)
case types.History:
data.Histories, err = extractHistories(path)
case types.Download:
data.Downloads, err = extractDownloads(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.CreditCard:
data.CreditCards, err = extractCreditCards(keys, path)
case types.Extension:
data.Extensions, err = extractExtensions(path)
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.SessionStorage:
data.SessionStorage, err = extractSessionStorage(path)
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, p.label(), err)
}
}
// countCategory calls the appropriate count function for a category.
func (p *profile) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(path)
case types.Cookie:
count, err = countCookies(path)
case types.History:
count, err = countHistories(path)
case types.Download:
count, err = countDownloads(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.CreditCard:
if p.kind == types.ChromiumYandex {
count, err = countYandexCreditCards(path)
} else {
count, err = countCreditCards(path)
}
case types.Extension:
if p.kind == types.ChromiumOpera {
count, err = countOperaExtensions(path)
} else {
count, err = countExtensions(path)
}
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.SessionStorage:
count, err = countSessionStorage(path)
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, p.label(), err)
}
return count
}
+119
View File
@@ -0,0 +1,119 @@
package chromium
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/crypto/keyretriever"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/types"
)
// TestExtractCategory_CustomExtractor verifies that extractCategory dispatches
// through a registered extractor instead of the default switch logic.
func TestExtractCategory_CustomExtractor(t *testing.T) {
// Create a profile with a custom extractor that records it was called
called := false
testExtractor := extensionExtractor{
fn: func(path string) ([]types.ExtensionEntry, error) {
called = true
return []types.ExtensionEntry{{Name: "custom", ID: "test-id"}}, nil
},
}
p := &profile{
extractors: map[types.Category]categoryExtractor{
types.Extension: testExtractor,
},
}
data := &types.BrowserData{}
p.extractCategory(data, types.Extension, keyretriever.MasterKeys{}, "unused-path")
assert.True(t, called, "custom extractor should be called")
require.Len(t, data.Extensions, 1)
assert.Equal(t, "custom", data.Extensions[0].Name)
}
// TestExtractCategory_DefaultFallback verifies that extractCategory uses
// the default switch when no extractor is registered.
func TestExtractCategory_DefaultFallback(t *testing.T) {
path := createTestDB(t, "History", urlsSchema,
insertURL("https://example.com", "Example", 3, 13350000000000000),
)
p := &profile{
extractors: nil, // no custom extractors
}
data := &types.BrowserData{}
p.extractCategory(data, types.History, keyretriever.MasterKeys{}, path)
require.Len(t, data.Histories, 1)
assert.Equal(t, "Example", data.Histories[0].Title)
}
// ---------------------------------------------------------------------------
// acquireFiles
// ---------------------------------------------------------------------------
func TestAcquireFiles(t *testing.T) {
profileDir := filepath.Join(fixture.chrome, "Default")
resolved := resolveSourcePaths(chromiumSources, profileDir)
p := &profile{profileDir: profileDir, sourcePaths: resolved}
session, err := filemanager.NewSession()
require.NoError(t, err)
defer session.Cleanup()
cats := []types.Category{types.History, types.Cookie, types.Bookmark}
paths := p.acquireFiles(session, cats)
assert.Len(t, paths, len(cats))
for _, path := range paths {
_, err := os.Stat(path)
require.NoError(t, err, "acquired file should exist")
}
}
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := setupHistoryDB(t)
p := &profile{kind: types.Chromium}
assert.Equal(t, 3, p.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := setupCookieDB(t)
p := &profile{kind: types.Chromium}
assert.Equal(t, 2, p.countCategory(types.Cookie, path))
})
t.Run("Bookmark", func(t *testing.T) {
path := setupBookmarkJSON(t)
p := &profile{kind: types.Chromium}
assert.Equal(t, 3, p.countCategory(types.Bookmark, path))
})
t.Run("Extension_Opera", func(t *testing.T) {
path := createTestJSON(t, "Secure Preferences", `{
"extensions": {
"opsettings": {
"ext1": {"location": 1, "manifest": {"name": "Ext", "version": "1.0"}}
}
}
}`)
p := &profile{kind: types.ChromiumOpera}
assert.Equal(t, 1, p.countCategory(types.Extension, path))
})
t.Run("FileNotFound", func(t *testing.T) {
p := &profile{kind: types.Chromium}
assert.Equal(t, 0, p.countCategory(types.History, "/nonexistent/path"))
})
}
+43 -165
View File
@@ -7,170 +7,74 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
) )
// Browser represents a single Firefox profile ready for extraction. // Browser is one Firefox installation: the Profiles directory holding one or
// more profiles. Firefox keys are per-profile (each profile's key4.db), so the
// installation does not implement KeyManager.
type Browser struct { type Browser struct {
cfg types.BrowserConfig cfg types.BrowserConfig
profileDir string // absolute path to profile directory profiles []*profile
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
} }
// NewBrowsers discovers Firefox profiles under cfg.UserDataDir and returns // NewBrowser discovers the Firefox profiles under cfg.UserDataDir and returns
// one Browser per profile. Firefox profile directories have random names // the installation, or nil if no profile with resolvable sources exists.
// (e.g. "97nszz88.default-release"); any subdirectory containing known // Firefox profile directories have random names (e.g. "97nszz88.default-release");
// data files is treated as a valid profile. // any subdirectory containing known data files is treated as a valid profile.
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
profileDirs := discoverProfiles(cfg.UserDataDir, firefoxSources) var profiles []*profile
if len(profileDirs) == 0 { for _, profileDir := range discoverProfiles(cfg.UserDataDir, firefoxSources) {
return nil, nil
}
var browsers []*Browser
for _, profileDir := range profileDirs {
sourcePaths := resolveSourcePaths(firefoxSources, profileDir) sourcePaths := resolveSourcePaths(firefoxSources, profileDir)
if len(sourcePaths) == 0 { if len(sourcePaths) == 0 {
continue continue
} }
browsers = append(browsers, &Browser{ profiles = append(profiles, &profile{
cfg: cfg,
profileDir: profileDir, profileDir: profileDir,
sources: firefoxSources, browserName: cfg.Name,
sourcePaths: sourcePaths, sourcePaths: sourcePaths,
}) })
} }
return browsers, nil if len(profiles) == 0 {
return nil, nil
}
return &Browser{cfg: cfg, profiles: profiles}, nil
} }
func (b *Browser) BrowserName() string { return b.cfg.Name } func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir } func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" { // Profiles returns the identity of every profile in this installation.
return "" func (b *Browser) Profiles() []types.Profile {
out := make([]types.Profile, 0, len(b.profiles))
for _, p := range b.profiles {
out = append(out, types.Profile{Name: p.name(), Dir: p.profileDir})
} }
return filepath.Base(b.profileDir) return out
} }
// Extract copies browser files to a temp directory, retrieves the master key, // Extract extracts every profile, deriving each profile's key independently.
// and extracts data for the requested categories. func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) {
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) { results := make([]types.ExtractResult, 0, len(b.profiles))
session, err := filemanager.NewSession() for _, p := range b.profiles {
if err != nil { results = append(results, types.ExtractResult{
return nil, err Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
Data: p.extract(categories),
})
} }
defer session.Cleanup() return results, nil
tempPaths := b.acquireFiles(session, categories)
masterKey, err := b.getMasterKey(session, tempPaths)
if err != nil {
log.Debugf("get master key for %s: %v", b.BrowserName()+"/"+b.ProfileName(), err)
}
data := &types.BrowserData{}
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
b.extractCategory(data, cat, masterKey, path)
}
return data, nil
} }
// CountEntries copies browser files to a temp directory and counts entries // CountEntries counts entries per category for every profile without decryption.
// per category without decryption. Much faster than Extract for display-only func (b *Browser) CountEntries(categories []types.Category) ([]types.CountResult, error) {
// use cases like "list --detail". results := make([]types.CountResult, 0, len(b.profiles))
func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]int, error) { for _, p := range b.profiles {
session, err := filemanager.NewSession() results = append(results, types.CountResult{
if err != nil { Profile: types.Profile{Name: p.name(), Dir: p.profileDir},
return nil, err Counts: p.count(categories),
})
} }
defer session.Cleanup() return results, nil
tempPaths := b.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = b.countCategory(cat, path)
}
return counts, nil
}
// countCategory calls the appropriate count function for a category.
func (b *Browser) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(path)
case types.Cookie:
count, err = countCookies(path)
case types.History:
count, err = countHistories(path)
case types.Download:
count, err = countDownloads(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.Extension:
count, err = countExtensions(path)
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.CreditCard, types.SessionStorage:
// Firefox does not support CreditCard or SessionStorage.
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
return count
}
// acquireFiles copies source files to the session temp directory.
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := b.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
// getMasterKey retrieves the Firefox master encryption key from key4.db.
// The key is derived via NSS ASN1 PBE decryption (platform-agnostic).
// If logins.json was already acquired by acquireFiles, the derived key
// is validated by attempting to decrypt an actual login entry.
func (b *Browser) getMasterKey(session *filemanager.Session, tempPaths map[types.Category]string) ([]byte, error) {
key4Src := filepath.Join(b.profileDir, "key4.db")
if !fileutil.FileExists(key4Src) {
return nil, nil
}
key4Dst := filepath.Join(session.TempDir(), "key4.db")
if err := session.Acquire(key4Src, key4Dst, false); err != nil {
return nil, fmt.Errorf("acquire key4.db: %w", err)
}
// logins.json is already acquired by acquireFiles as the Password source;
// reuse it for master key validation if available.
loginsPath := tempPaths[types.Password]
return retrieveMasterKey(key4Dst, loginsPath)
} }
// retrieveMasterKey reads key4.db and derives the master key using NSS. // retrieveMasterKey reads key4.db and derives the master key using NSS.
@@ -203,32 +107,6 @@ func retrieveMasterKey(key4Path, loginsPath string) ([]byte, error) {
return nil, fmt.Errorf("derived %d key(s) but none could decrypt logins", len(keys)) return nil, fmt.Errorf("derived %d key(s) but none could decrypt logins", len(keys))
} }
// extractCategory calls the appropriate extract function for a category.
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) {
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(masterKey, path)
case types.Cookie:
data.Cookies, err = extractCookies(path)
case types.History:
data.Histories, err = extractHistories(path)
case types.Download:
data.Downloads, err = extractDownloads(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.Extension:
data.Extensions, err = extractExtensions(path)
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.CreditCard, types.SessionStorage:
// Firefox does not support CreditCard or SessionStorage extraction.
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
}
// resolvedPath holds the absolute path and type for a discovered source. // resolvedPath holds the absolute path and type for a discovered source.
type resolvedPath struct { type resolvedPath struct {
absPath string absPath string
+11 -127
View File
@@ -117,18 +117,19 @@ func TestNewBrowsers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := types.BrowserConfig{Name: "Firefox", Kind: types.Firefox, UserDataDir: tt.dir} cfg := types.BrowserConfig{Name: "Firefox", Kind: types.Firefox, UserDataDir: tt.dir}
browsers, err := NewBrowsers(cfg) b, err := NewBrowser(cfg)
require.NoError(t, err) require.NoError(t, err)
if len(tt.wantProfiles) == 0 { if len(tt.wantProfiles) == 0 {
assert.Empty(t, browsers) assert.Nil(t, b)
return return
} }
require.Len(t, browsers, len(tt.wantProfiles)) require.NotNil(t, b)
require.Len(t, b.profiles, len(tt.wantProfiles))
profileNames := make(map[string]bool) profileNames := make(map[string]bool)
for _, b := range browsers { for _, p := range b.profiles {
profileNames[filepath.Base(b.profileDir)] = true profileNames[filepath.Base(p.profileDir)] = true
} }
for _, want := range tt.wantProfiles { for _, want := range tt.wantProfiles {
assert.True(t, profileNames[want], "should find profile %s", want) assert.True(t, profileNames[want], "should find profile %s", want)
@@ -187,134 +188,17 @@ func TestCountEntries(t *testing.T) {
mkDir(profileDir) mkDir(profileDir)
installFile(t, profileDir, setupMozHistoryDB(t), "places.sqlite") installFile(t, profileDir, setupMozHistoryDB(t), "places.sqlite")
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Firefox", Kind: types.Firefox, UserDataDir: dir, Name: "Firefox", Kind: types.Firefox, UserDataDir: dir,
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 1) require.NotNil(t, b)
// CountEntries works without master key. // CountEntries works without master key.
counts, err := browsers[0].CountEntries([]types.Category{types.History}) results, err := b.CountEntries([]types.Category{types.History})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 3, counts[types.History]) require.Len(t, results, 1)
} assert.Equal(t, 3, results[0].Counts[types.History])
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := setupMozHistoryDB(t)
b := &Browser{}
assert.Equal(t, 3, b.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := setupMozCookieDB(t)
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
})
t.Run("Bookmark", func(t *testing.T) {
path := setupMozBookmarkDB(t)
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Bookmark, path))
})
t.Run("Extension", func(t *testing.T) {
path := setupMozExtensionJSON(t)
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Extension, path))
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused"))
assert.Equal(t, 0, b.countCategory(types.SessionStorage, "unused"))
})
}
// ---------------------------------------------------------------------------
// extractCategory
// ---------------------------------------------------------------------------
// TestExtractCategory verifies that the switch dispatch works for each category.
func TestExtractCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "places.sqlite",
[]string{mozPlacesSchema},
insertMozPlace(1, "https://example.com", "Example", 3, 1000000),
insertMozPlace(2, "https://go.dev", "Go", 1, 2000000),
)
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.History, nil, path)
require.Len(t, data.Histories, 2)
// Firefox sorts by visit count ascending
assert.Equal(t, 1, data.Histories[0].VisitCount)
assert.Equal(t, 3, data.Histories[1].VisitCount)
})
t.Run("Cookie", func(t *testing.T) {
path := createTestDB(t, "cookies.sqlite",
[]string{mozCookiesSchema},
insertMozCookie("session", "abc", ".example.com", "/", 1000000000000, 0, 0, 0),
)
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Cookie, nil, path)
require.Len(t, data.Cookies, 1)
assert.Equal(t, "session", data.Cookies[0].Name)
assert.Equal(t, "abc", data.Cookies[0].Value) // Firefox cookies are not encrypted
})
t.Run("Bookmark", func(t *testing.T) {
path := createTestDB(t, "places.sqlite",
[]string{mozPlacesSchema, mozBookmarksSchema},
insertMozPlace(1, "https://github.com", "GitHub", 1, 1000000),
insertMozBookmark(1, 1, 1, "GitHub", 1000000),
)
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Bookmark, nil, path)
require.Len(t, data.Bookmarks, 1)
assert.Equal(t, "GitHub", data.Bookmarks[0].Name)
})
t.Run("Extension", func(t *testing.T) {
path := createTestJSON(t, "extensions.json", `{
"addons": [
{
"id": "ublock@example.com",
"location": "app-profile",
"active": true,
"version": "1.0",
"defaultLocale": {"name": "uBlock Origin", "description": "Ad blocker"}
},
{
"id": "system@mozilla.com",
"location": "app-system-defaults",
"active": true
}
]
}`)
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Extension, nil, path)
require.Len(t, data.Extensions, 1) // system extension skipped
assert.Equal(t, "uBlock Origin", data.Extensions[0].Name)
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
data := &types.BrowserData{}
// CreditCard and SessionStorage are not supported by Firefox
b.extractCategory(data, types.CreditCard, nil, "unused")
b.extractCategory(data, types.SessionStorage, nil, "unused")
assert.Empty(t, data.CreditCards)
assert.Empty(t, data.SessionStorage)
})
} }
// Anchor: 2024-01-15T10:30:00Z. // Anchor: 2024-01-15T10:30:00Z.
+169
View File
@@ -0,0 +1,169 @@
package firefox
import (
"fmt"
"path/filepath"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
)
// profile is one Firefox profile — the leaf extraction unit. Unlike Chromium,
// each Firefox profile owns its own master key (derived from its key4.db).
type profile struct {
profileDir string
browserName string
sourcePaths map[types.Category]resolvedPath
}
func (p *profile) name() string {
if p.profileDir == "" {
return ""
}
return filepath.Base(p.profileDir)
}
func (p *profile) label() string { return p.browserName + "/" + p.name() }
// extract copies the profile's source files to a temp directory, derives the
// per-profile master key, and extracts the requested categories.
func (p *profile) extract(categories []types.Category) *types.BrowserData {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return &types.BrowserData{}
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
masterKey, err := p.getMasterKey(session, tempPaths)
if err != nil {
log.Debugf("get master key for %s: %v", p.label(), err)
}
data := &types.BrowserData{}
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
p.extractCategory(data, cat, masterKey, path)
}
return data
}
// count counts entries per category without decryption.
func (p *profile) count(categories []types.Category) map[types.Category]int {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return nil
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = p.countCategory(cat, path)
}
return counts
}
// acquireFiles copies source files to the session temp directory.
func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := p.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
// getMasterKey retrieves the Firefox master encryption key from this profile's
// key4.db. The key is derived via NSS ASN1 PBE decryption (platform-agnostic).
// If logins.json was already acquired by acquireFiles, the derived key is
// validated by attempting to decrypt an actual login entry.
func (p *profile) getMasterKey(session *filemanager.Session, tempPaths map[types.Category]string) ([]byte, error) {
key4Src := filepath.Join(p.profileDir, "key4.db")
if !fileutil.FileExists(key4Src) {
return nil, nil
}
key4Dst := filepath.Join(session.TempDir(), "key4.db")
if err := session.Acquire(key4Src, key4Dst, false); err != nil {
return nil, fmt.Errorf("acquire key4.db: %w", err)
}
// logins.json is already acquired by acquireFiles as the Password source;
// reuse it for master key validation if available.
loginsPath := tempPaths[types.Password]
return retrieveMasterKey(key4Dst, loginsPath)
}
// extractCategory calls the appropriate extract function for a category.
func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) {
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(masterKey, path)
case types.Cookie:
data.Cookies, err = extractCookies(path)
case types.History:
data.Histories, err = extractHistories(path)
case types.Download:
data.Downloads, err = extractDownloads(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.Extension:
data.Extensions, err = extractExtensions(path)
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.CreditCard, types.SessionStorage:
// Firefox does not support CreditCard or SessionStorage extraction.
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, p.label(), err)
}
}
// countCategory calls the appropriate count function for a category.
func (p *profile) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(path)
case types.Cookie:
count, err = countCookies(path)
case types.History:
count, err = countHistories(path)
case types.Download:
count, err = countDownloads(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.Extension:
count, err = countExtensions(path)
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.CreditCard, types.SessionStorage:
// Firefox does not support CreditCard or SessionStorage.
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, p.label(), err)
}
return count
}
+128
View File
@@ -0,0 +1,128 @@
package firefox
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/types"
)
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := setupMozHistoryDB(t)
p := &profile{}
assert.Equal(t, 3, p.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := setupMozCookieDB(t)
p := &profile{}
assert.Equal(t, 2, p.countCategory(types.Cookie, path))
})
t.Run("Bookmark", func(t *testing.T) {
path := setupMozBookmarkDB(t)
p := &profile{}
assert.Equal(t, 2, p.countCategory(types.Bookmark, path))
})
t.Run("Extension", func(t *testing.T) {
path := setupMozExtensionJSON(t)
p := &profile{}
assert.Equal(t, 2, p.countCategory(types.Extension, path))
})
t.Run("UnsupportedCategory", func(t *testing.T) {
p := &profile{}
assert.Equal(t, 0, p.countCategory(types.CreditCard, "unused"))
assert.Equal(t, 0, p.countCategory(types.SessionStorage, "unused"))
})
}
// ---------------------------------------------------------------------------
// extractCategory
// ---------------------------------------------------------------------------
// TestExtractCategory verifies that the switch dispatch works for each category.
func TestExtractCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "places.sqlite",
[]string{mozPlacesSchema},
insertMozPlace(1, "https://example.com", "Example", 3, 1000000),
insertMozPlace(2, "https://go.dev", "Go", 1, 2000000),
)
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.History, nil, path)
require.Len(t, data.Histories, 2)
// Firefox sorts by visit count ascending
assert.Equal(t, 1, data.Histories[0].VisitCount)
assert.Equal(t, 3, data.Histories[1].VisitCount)
})
t.Run("Cookie", func(t *testing.T) {
path := createTestDB(t, "cookies.sqlite",
[]string{mozCookiesSchema},
insertMozCookie("session", "abc", ".example.com", "/", 1000000000000, 0, 0, 0),
)
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Cookie, nil, path)
require.Len(t, data.Cookies, 1)
assert.Equal(t, "session", data.Cookies[0].Name)
assert.Equal(t, "abc", data.Cookies[0].Value) // Firefox cookies are not encrypted
})
t.Run("Bookmark", func(t *testing.T) {
path := createTestDB(t, "places.sqlite",
[]string{mozPlacesSchema, mozBookmarksSchema},
insertMozPlace(1, "https://github.com", "GitHub", 1, 1000000),
insertMozBookmark(1, 1, 1, "GitHub", 1000000),
)
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Bookmark, nil, path)
require.Len(t, data.Bookmarks, 1)
assert.Equal(t, "GitHub", data.Bookmarks[0].Name)
})
t.Run("Extension", func(t *testing.T) {
path := createTestJSON(t, "extensions.json", `{
"addons": [
{
"id": "ublock@example.com",
"location": "app-profile",
"active": true,
"version": "1.0",
"defaultLocale": {"name": "uBlock Origin", "description": "Ad blocker"}
},
{
"id": "system@mozilla.com",
"location": "app-system-defaults",
"active": true
}
]
}`)
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Extension, nil, path)
require.Len(t, data.Extensions, 1) // system extension skipped
assert.Equal(t, "uBlock Origin", data.Extensions[0].Name)
})
t.Run("UnsupportedCategory", func(t *testing.T) {
p := &profile{}
data := &types.BrowserData{}
// CreditCard and SessionStorage are not supported by Firefox
p.extractCategory(data, types.CreditCard, nil, "unused")
p.extractCategory(data, types.SessionStorage, nil, "unused")
assert.Empty(t, data.CreditCards)
assert.Empty(t, data.SessionStorage)
})
}
+33 -53
View File
@@ -7,77 +7,57 @@ import (
"github.com/moond4rk/hackbrowserdata/log" "github.com/moond4rk/hackbrowserdata/log"
) )
// BuildDump exports per-installation master keys; profiles sharing (Browser, UserDataDir) collapse into one Vault. // BuildDump exports per-installation master keys. Each Browser is one installation,
// Browsers without KeyManager (Firefox/Safari) are skipped. ExportKeys is invoked exactly once per installation // so this is a straight one-Vault-per-installation map: ExportKeys is invoked once
// regardless of profile count or success. Partial results (e.g. V10 retrieved, V20 failed) keep the usable tiers // per installation. Installations without KeyManager (Firefox/Safari) are skipped.
// rather than discarding the vault, matching getMasterKeys' behavior on the extraction path — a Chrome 127+ // Partial results (e.g. V10 retrieved, V20 failed) keep the usable tiers rather than
// profile mixes v10 + v20 ciphertexts and a v20-only failure must not erase a usable v10 key. // discarding the vault — a Chrome 127+ profile mixes v10 + v20 ciphertexts and a
// v20-only failure must not erase a usable v10 key.
func BuildDump(browsers []Browser) keyretriever.Dump { func BuildDump(browsers []Browser) keyretriever.Dump {
dump := keyretriever.NewDump() dump := keyretriever.NewDump()
groups, order := groupByInstallation(browsers) for _, b := range browsers {
for _, key := range order { km, ok := b.(KeyManager)
g := groups[key] if !ok {
keys, err := g.km.ExportKeys() continue
}
keys, err := km.ExportKeys()
if err != nil { if err != nil {
status := "partial" status := "partial"
if !keys.HasAny() { if !keys.HasAny() {
status = "failed" status = "failed"
} }
log.Warnf("dump-keys: %s/%s %s: %v", g.browser, g.profiles[0], status, err) log.Warnf("dump-keys: %s %s: %v", b.BrowserName(), status, err)
} }
if !keys.HasAny() { if !keys.HasAny() {
continue continue
} }
dump.Vaults = append(dump.Vaults, keyretriever.Vault{ dump.Vaults = append(dump.Vaults, keyretriever.Vault{
Browser: g.browser, Browser: b.BrowserName(),
UserDataDir: g.userDataDir, UserDataDir: b.UserDataDir(),
Profiles: g.profiles, Profiles: profileNames(b),
Keys: keys, Keys: keys,
}) })
} }
return dump return dump
} }
type installGroup struct { func profileNames(b Browser) []string {
browser, userDataDir string profiles := b.Profiles()
km KeyManager names := make([]string, 0, len(profiles))
profiles []string for _, p := range profiles {
} names = append(names, p.Name)
// groupByInstallation collects browsers into per-installation groups keyed by (BrowserName, UserDataDir),
// preserving the discovery order of the first profile in each group. Non-KeyManager browsers are skipped.
// Doing the grouping up front (rather than checking dump.Vaults profile-by-profile) makes the resulting
// Profiles list complete and order-independent even if the group's ExportKeys later fails.
func groupByInstallation(browsers []Browser) (map[string]*installGroup, []string) {
groups := make(map[string]*installGroup)
var order []string
for _, b := range browsers {
km, ok := b.(KeyManager)
if !ok {
continue
}
key := b.BrowserName() + "|" + b.UserDataDir()
if g, exists := groups[key]; exists {
g.profiles = append(g.profiles, b.ProfileName())
continue
}
groups[key] = &installGroup{
browser: b.BrowserName(),
userDataDir: b.UserDataDir(),
km: km,
profiles: []string{b.ProfileName()},
}
order = append(order, key)
} }
return groups, order return names
} }
// ApplyDump installs master keys from dump onto matching browsers, replacing each browser's default // ApplyDump installs master keys from dump onto matching installations, replacing
// platform-native retrievers with StaticProviders backed by the Dump's bytes. Matching is by // each installation's default platform-native retrievers with StaticProviders
// (BrowserName, UserDataDir) — the same key BuildDump groups by. When exact match fails (commonly a // backed by the Dump's bytes. Matching is by (BrowserName, UserDataDir) — the same
// cross-host path mismatch: Windows backslash vs POSIX, or a relocated User Data dir via -p), falls // key BuildDump emits. When exact match fails (commonly a cross-host path mismatch:
// back to the sole vault for that browser name when one exists. Browsers without a matching vault // Windows backslash vs POSIX, or a relocated User Data dir via -p), falls back to
// are warned and left untouched; non-KeyManager browsers (Firefox/Safari) are skipped silently. // the sole vault for that browser name when one exists. Installations without a
// matching vault are warned and left untouched; non-KeyManager installations
// (Firefox/Safari) are skipped silently.
func ApplyDump(browsers []Browser, dump keyretriever.Dump) { func ApplyDump(browsers []Browser, dump keyretriever.Dump) {
if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS { if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS {
log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s", log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s",
@@ -99,13 +79,13 @@ func ApplyDump(browsers []Browser, dump keyretriever.Dump) {
if !found { if !found {
if candidates := vaultsByBrowser[b.BrowserName()]; len(candidates) == 1 { if candidates := vaultsByBrowser[b.BrowserName()]; len(candidates) == 1 {
v = candidates[0] v = candidates[0]
log.Infof("apply-keys: %s/%s using sole vault for browser (dump path %q != local %q)", log.Infof("apply-keys: %s using sole vault for browser (dump path %q != local %q)",
b.BrowserName(), b.ProfileName(), v.UserDataDir, b.UserDataDir()) b.BrowserName(), v.UserDataDir, b.UserDataDir())
found = true found = true
} }
} }
if !found { if !found {
log.Warnf("apply-keys: %s/%s no matching vault in dump", b.BrowserName(), b.ProfileName()) log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName())
continue continue
} }
km.SetKeyRetrievers(keyretriever.Retrievers{ km.SetKeyRetrievers(keyretriever.Retrievers{
+39 -60
View File
@@ -17,20 +17,28 @@ const (
testEdgeName = "Edge" testEdgeName = "Edge"
) )
// mockBrowser is one installation holding zero or more profile names.
type mockBrowser struct { type mockBrowser struct {
name, profile, profileDir, userDataDir string name, userDataDir string
profiles []string
} }
func (m *mockBrowser) BrowserName() string { return m.name } func (m *mockBrowser) BrowserName() string { return m.name }
func (m *mockBrowser) ProfileName() string { return m.profile }
func (m *mockBrowser) ProfileDir() string { return m.profileDir }
func (m *mockBrowser) UserDataDir() string { return m.userDataDir } func (m *mockBrowser) UserDataDir() string { return m.userDataDir }
func (m *mockBrowser) Extract(_ []types.Category) (*types.BrowserData, error) { func (m *mockBrowser) Profiles() []types.Profile {
return &types.BrowserData{}, nil out := make([]types.Profile, 0, len(m.profiles))
for _, p := range m.profiles {
out = append(out, types.Profile{Name: p, Dir: m.userDataDir + "/" + p})
}
return out
} }
func (m *mockBrowser) CountEntries(_ []types.Category) (map[types.Category]int, error) { func (m *mockBrowser) Extract(_ []types.Category) ([]types.ExtractResult, error) {
return nil, nil
}
func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, error) {
return nil, nil return nil, nil
} }
@@ -66,7 +74,7 @@ func TestBuildDump_Empty(t *testing.T) {
func TestBuildDump_SingleChromium(t *testing.T) { func TestBuildDump_SingleChromium(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, profileDir: "/p/Default", userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10-key")}, keys: keyretriever.MasterKeys{V10: []byte("v10-key")},
} }
@@ -87,32 +95,34 @@ func TestBuildDump_SingleChromium(t *testing.T) {
} }
} }
func TestBuildDump_MultipleProfilesSameInstallation(t *testing.T) { // TestBuildDump_MultipleProfilesOneVault verifies that one installation holding
p1 := &mockChromiumBrowser{ // multiple profiles produces a single vault with all profile names, deriving the
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, // key exactly once.
func TestBuildDump_MultipleProfilesOneVault(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault, testProfile1}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: keyretriever.MasterKeys{V10: []byte("v10")},
} }
p2 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD},
exportErr: errors.New("ExportKeys should not be called for second profile"),
}
dump := BuildDump([]Browser{p1, p2}) dump := BuildDump([]Browser{b})
if len(dump.Vaults) != 1 { if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (same installation grouping)", len(dump.Vaults)) t.Fatalf("Vaults len = %d, want 1 (one installation = one vault)", len(dump.Vaults))
} }
if len(dump.Vaults[0].Profiles) != 2 { if len(dump.Vaults[0].Profiles) != 2 {
t.Errorf("Profiles = %v, want both profiles", dump.Vaults[0].Profiles) t.Errorf("Profiles = %v, want both profiles", dump.Vaults[0].Profiles)
} }
if b.calls != 1 {
t.Errorf("ExportKeys calls = %d, want 1 (one call per installation)", b.calls)
}
} }
func TestBuildDump_SkipsNonKeyManager(t *testing.T) { func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
chrome := &mockChromiumBrowser{ chrome := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: keyretriever.MasterKeys{V10: []byte("v10")},
} }
firefox := &mockBrowser{name: firefoxName, profile: "default-release", userDataDir: "/ff"} firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
dump := BuildDump([]Browser{chrome, firefox}) dump := BuildDump([]Browser{chrome, firefox})
@@ -126,11 +136,11 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
func TestBuildDump_SkipsExportError(t *testing.T) { func TestBuildDump_SkipsExportError(t *testing.T) {
good := &mockChromiumBrowser{ good := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/chrome"}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/chrome", profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: keyretriever.MasterKeys{V10: []byte("v10")},
} }
failing := &mockChromiumBrowser{ failing := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: testEdgeName, profile: testProfileDefault, userDataDir: "/edge"}, mockBrowser: mockBrowser{name: testEdgeName, userDataDir: "/edge", profiles: []string{testProfileDefault}},
exportErr: errors.New("retriever failed"), exportErr: errors.New("retriever failed"),
} }
@@ -146,7 +156,7 @@ func TestBuildDump_SkipsExportError(t *testing.T) {
func TestBuildDump_JSONRoundTrip(t *testing.T) { func TestBuildDump_JSONRoundTrip(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}}, keys: keyretriever.MasterKeys{V10: []byte{0x01, 0x02, 0x03}, V20: []byte{0xff, 0xee}},
} }
@@ -181,7 +191,7 @@ func TestBuildDump_JSONRoundTrip(t *testing.T) {
func TestBuildDump_PartialKeys(t *testing.T) { func TestBuildDump_PartialKeys(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10")}, keys: keyretriever.MasterKeys{V10: []byte("v10")},
exportErr: errors.New("v20: ABE failed"), exportErr: errors.New("v20: ABE failed"),
} }
@@ -201,7 +211,7 @@ func TestBuildDump_PartialKeys(t *testing.T) {
func TestApplyDump_Match(t *testing.T) { func TestApplyDump_Match(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := keyretriever.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keyretriever.Vault{
@@ -224,7 +234,7 @@ func TestApplyDump_Match(t *testing.T) {
func TestApplyDump_MissingVault(t *testing.T) { func TestApplyDump_MissingVault(t *testing.T) {
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := keyretriever.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keyretriever.Vault{
@@ -239,7 +249,7 @@ func TestApplyDump_MissingVault(t *testing.T) {
} }
func TestApplyDump_NonKeyManagerSkipped(t *testing.T) { func TestApplyDump_NonKeyManagerSkipped(t *testing.T) {
firefox := &mockBrowser{name: firefoxName, profile: "default-release", userDataDir: "/ff"} firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
dump := keyretriever.Dump{ dump := keyretriever.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keyretriever.Vault{
{Browser: firefoxName, UserDataDir: "/ff", Keys: keyretriever.MasterKeys{V10: []byte("v10")}}, {Browser: firefoxName, UserDataDir: "/ff", Keys: keyretriever.MasterKeys{V10: []byte("v10")}},
@@ -251,13 +261,13 @@ func TestApplyDump_NonKeyManagerSkipped(t *testing.T) {
func TestApplyDump_RoundTrip(t *testing.T) { func TestApplyDump_RoundTrip(t *testing.T) {
src := &mockChromiumBrowser{ src := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: keyretriever.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")}, keys: keyretriever.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")},
} }
dump := BuildDump([]Browser{src}) dump := BuildDump([]Browser{src})
dst := &mockChromiumBrowser{ dst := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD}, mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
} }
ApplyDump([]Browser{dst}, dump) ApplyDump([]Browser{dst}, dump)
@@ -279,7 +289,7 @@ func TestApplyDump_FallbackOnPathMismatch(t *testing.T) {
// UserDataDir literally differs. With a single vault for the browser, ApplyDump should still // UserDataDir literally differs. With a single vault for the browser, ApplyDump should still
// inject — otherwise the primary cross-host use case fails silently. // inject — otherwise the primary cross-host use case fails silently.
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/local/chrome"}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := keyretriever.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keyretriever.Vault{
@@ -305,7 +315,7 @@ func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) {
// Two Chrome vaults in the dump and no exact path match — ApplyDump must not guess which // Two Chrome vaults in the dump and no exact path match — ApplyDump must not guess which
// installation the local browser corresponds to. // installation the local browser corresponds to.
b := &mockChromiumBrowser{ b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: "/local/chrome"}, mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
} }
dump := keyretriever.Dump{ dump := keyretriever.Dump{
Vaults: []keyretriever.Vault{ Vaults: []keyretriever.Vault{
@@ -319,34 +329,3 @@ func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) {
t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10) t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10)
} }
} }
func TestBuildDump_GroupingOrderIndependent(t *testing.T) {
for _, name := range []string{"p1 first", "p2 first"} {
t.Run(name, func(t *testing.T) {
p1 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfileDefault, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
p2 := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, profile: testProfile1, userDataDir: testUDD},
keys: keyretriever.MasterKeys{V10: []byte("v10")},
}
list := []Browser{p1, p2}
if name == "p2 first" {
list = []Browser{p2, p1}
}
dump := BuildDump(list)
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults))
}
if len(dump.Vaults[0].Profiles) != 2 {
t.Errorf("Profiles = %v, want 2", dump.Vaults[0].Profiles)
}
if calls := p1.calls + p2.calls; calls != 1 {
t.Errorf("ExportKeys total calls = %d, want 1 (one call per installation)", calls)
}
})
}
}
+165
View File
@@ -0,0 +1,165 @@
package safari
import (
"path/filepath"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types"
)
// profile is one Safari profile — the leaf extraction unit. Passwords come from
// the shared macOS Keychain; everything else reads from the profile's directories.
type profile struct {
ctx profileContext
browserName string
sourcePaths map[types.Category]resolvedPath
}
func (p *profile) name() string { return p.ctx.name }
func (p *profile) label() string { return p.browserName + "/" + p.name() }
func (p *profile) dir() string {
if p.ctx.isDefault() {
return p.ctx.legacyHome
}
return filepath.Join(p.ctx.container, "Safari", "Profiles", p.ctx.uuidUpper)
}
func (p *profile) extract(categories []types.Category, keychainPassword string) *types.BrowserData {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return &types.BrowserData{}
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
data := &types.BrowserData{}
for _, cat := range categories {
// Keychain is user-scope, not per-profile — attribute only to default to avoid duplicates.
if cat == types.Password {
if p.ctx.isDefault() {
p.extractCategory(data, cat, "", keychainPassword)
}
continue
}
// Extension plists (AppExtensions + WebExtensions) live directly in the container
// and are read in-place; attribute to default only until per-profile layouts are verified.
if cat == types.Extension {
if p.ctx.isDefault() {
p.extractCategory(data, cat, "", keychainPassword)
}
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
}
p.extractCategory(data, cat, path, keychainPassword)
}
return data
}
func (p *profile) count(categories []types.Category, keychainPassword string) map[types.Category]int {
session, err := filemanager.NewSession()
if err != nil {
log.Debugf("new session for %s: %v", p.label(), err)
return nil
}
defer session.Cleanup()
tempPaths := p.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
if cat == types.Password {
if p.ctx.isDefault() {
counts[cat] = p.countCategory(cat, "", keychainPassword)
}
continue
}
if cat == types.Extension {
if p.ctx.isDefault() {
counts[cat] = p.countCategory(cat, "", keychainPassword)
}
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = p.countCategory(cat, path, keychainPassword)
}
return counts
}
func (p *profile) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := p.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
func (p *profile) extractCategory(data *types.BrowserData, cat types.Category, path, keychainPassword string) {
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(keychainPassword)
case types.History:
data.Histories, err = extractHistories(path)
case types.Cookie:
data.Cookies, err = extractCookies(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.Download:
data.Downloads, err = extractDownloads(path, p.ctx.downloadOwnerUUID())
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.Extension:
data.Extensions, err = extractExtensions(p.ctx.container)
default:
return
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, p.label(), err)
}
}
func (p *profile) countCategory(cat types.Category, path, keychainPassword string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(keychainPassword)
case types.History:
count, err = countHistories(path)
case types.Cookie:
count, err = countCookies(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.Download:
count, err = countDownloads(path, p.ctx.downloadOwnerUUID())
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.Extension:
count, err = countExtensions(p.ctx.container)
default:
// Unsupported categories silently return 0.
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, p.label(), err)
}
return count
}
+157
View File
@@ -0,0 +1,157 @@
package safari
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/types"
)
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "History.db",
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
insertHistoryItem(1, "https://example.com", "example.com", 1),
)
p := &profile{}
assert.Equal(t, 1, p.countCategory(types.History, path, ""))
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{domain: ".example.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
{domain: ".go.dev", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
})
p := &profile{}
assert.Equal(t, 2, p.countCategory(types.Cookie, path, ""))
})
t.Run("Bookmark", func(t *testing.T) {
path := buildTestBookmarksPlist(t, safariBookmark{
Type: bookmarkTypeList,
Children: []safariBookmark{
{Type: bookmarkTypeLeaf, URLString: "https://a.com", URIDictionary: uriDictionary{Title: "A"}},
{Type: bookmarkTypeLeaf, URLString: "https://b.com", URIDictionary: uriDictionary{Title: "B"}},
},
})
p := &profile{}
assert.Equal(t, 2, p.countCategory(types.Bookmark, path, ""))
})
t.Run("Download", func(t *testing.T) {
path := buildTestDownloadsPlist(t, safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 100},
},
})
p := &profile{}
assert.Equal(t, 1, p.countCategory(types.Download, path, ""))
})
t.Run("LocalStorage", func(t *testing.T) {
dir := buildTestLocalStorageDir(t, map[string][]testLocalStorageItem{
"https://example.com": {{Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}},
"https://go.dev": {{Key: "theme", Value: "dark"}},
})
p := &profile{}
assert.Equal(t, 3, p.countCategory(types.LocalStorage, dir, ""))
})
t.Run("UnsupportedCategory", func(t *testing.T) {
p := &profile{}
assert.Equal(t, 0, p.countCategory(types.CreditCard, "unused", ""))
assert.Equal(t, 0, p.countCategory(types.SessionStorage, "unused", ""))
})
}
func TestExtractCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "History.db",
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
insertHistoryItem(1, "https://example.com", "example.com", 3),
insertHistoryItem(2, "https://go.dev", "go.dev", 1),
insertHistoryVisit(1, 1, 700000000.0, "Example"),
insertHistoryVisit(2, 2, 700000000.0, "Go"),
)
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.History, path, "")
require.Len(t, data.Histories, 2)
// Sorted by visit count descending
assert.Equal(t, 3, data.Histories[0].VisitCount)
assert.Equal(t, 1, data.Histories[1].VisitCount)
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{
domain: ".example.com", name: "session", path: "/", value: "abc",
secure: true, httpOnly: true, expires: 2000000000.0, creation: 700000000.0,
},
})
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Cookie, path, "")
require.Len(t, data.Cookies, 1)
assert.Equal(t, ".example.com", data.Cookies[0].Host)
assert.Equal(t, "session", data.Cookies[0].Name)
assert.True(t, data.Cookies[0].IsSecure)
assert.True(t, data.Cookies[0].IsHTTPOnly)
})
t.Run("Bookmark", func(t *testing.T) {
path := buildTestBookmarksPlist(t, safariBookmark{
Type: bookmarkTypeList,
Children: []safariBookmark{
{Type: bookmarkTypeLeaf, URLString: "https://github.com", URIDictionary: uriDictionary{Title: "GitHub"}},
},
})
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Bookmark, path, "")
require.Len(t, data.Bookmarks, 1)
assert.Equal(t, "GitHub", data.Bookmarks[0].Name)
assert.Equal(t, "https://github.com", data.Bookmarks[0].URL)
})
t.Run("Download", func(t *testing.T) {
path := buildTestDownloadsPlist(t, safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 1024},
},
})
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.Download, path, "")
require.Len(t, data.Downloads, 1)
assert.Equal(t, "https://example.com/file.zip", data.Downloads[0].URL)
assert.Equal(t, int64(1024), data.Downloads[0].TotalBytes)
})
t.Run("LocalStorage", func(t *testing.T) {
dir := buildTestLocalStorageDir(t, map[string][]testLocalStorageItem{
"https://github.com": {{Key: "theme", Value: "dark"}},
})
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.LocalStorage, dir, "")
require.Len(t, data.LocalStorage, 1)
assert.Equal(t, "https://github.com", data.LocalStorage[0].URL)
assert.Equal(t, "theme", data.LocalStorage[0].Key)
assert.Equal(t, "dark", data.LocalStorage[0].Value)
})
t.Run("UnsupportedCategory", func(t *testing.T) {
p := &profile{}
data := &types.BrowserData{}
p.extractCategory(data, types.CreditCard, "unused", "")
assert.Empty(t, data.CreditCards)
})
}
+53 -162
View File
@@ -2,194 +2,85 @@ package safari
import ( import (
"os" "os"
"path/filepath"
"time" "time"
"github.com/moond4rk/hackbrowserdata/filemanager"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/types"
) )
// Browser is one Safari profile's data ready for extraction. Passwords come from the shared macOS // Browser is one Safari installation, holding the default profile and any named
// Keychain; everything else reads from the profile's directories. // profiles. Passwords come from the shared macOS Keychain; the login password is
// set on the installation and threaded to each profile at extract time.
type Browser struct { type Browser struct {
cfg types.BrowserConfig cfg types.BrowserConfig
profile profileContext
keychainPassword string keychainPassword string
sourcePaths map[types.Category]resolvedPath profiles []*profile
} }
// SetKeychainPassword sets the macOS login password used to unlock the Keychain.
func (b *Browser) SetKeychainPassword(password string) { b.keychainPassword = password } func (b *Browser) SetKeychainPassword(password string) { b.keychainPassword = password }
// NewBrowsers returns one Browser per Safari profile with resolvable data. Named profiles are // NewBrowser returns the Safari installation with one profile per Safari profile
// enumerated from SafariTabs.db. // that has resolvable data, or nil if none. Named profiles are enumerated from
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) { // SafariTabs.db.
var browsers []*Browser func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
var profiles []*profile
for _, p := range discoverSafariProfiles(cfg.UserDataDir) { for _, p := range discoverSafariProfiles(cfg.UserDataDir) {
paths := resolveProfilePaths(p) paths := resolveProfilePaths(p)
if len(paths) == 0 { if len(paths) == 0 {
continue continue
} }
browsers = append(browsers, &Browser{ profiles = append(profiles, &profile{
cfg: cfg, ctx: p,
profile: p, browserName: cfg.Name,
sourcePaths: paths, sourcePaths: paths,
}) })
} }
return browsers, nil if len(profiles) == 0 {
return nil, nil
}
return &Browser{cfg: cfg, profiles: profiles}, nil
}
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
// Profiles returns the identity of every Safari profile in this installation.
func (b *Browser) Profiles() []types.Profile {
out := make([]types.Profile, 0, len(b.profiles))
for _, p := range b.profiles {
out = append(out, types.Profile{Name: p.ctx.name, Dir: p.dir()})
}
return out
}
// Extract extracts every profile, threading the installation's keychain password.
func (b *Browser) Extract(categories []types.Category) ([]types.ExtractResult, error) {
results := make([]types.ExtractResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.ExtractResult{
Profile: types.Profile{Name: p.ctx.name, Dir: p.dir()},
Data: p.extract(categories, b.keychainPassword),
})
}
return results, nil
}
// CountEntries counts entries per category for every profile.
func (b *Browser) CountEntries(categories []types.Category) ([]types.CountResult, error) {
results := make([]types.CountResult, 0, len(b.profiles))
for _, p := range b.profiles {
results = append(results, types.CountResult{
Profile: types.Profile{Name: p.ctx.name, Dir: p.dir()},
Counts: p.count(categories, b.keychainPassword),
})
}
return results, nil
} }
func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath { func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath {
return resolveSourcePaths(buildSources(p)) return resolveSourcePaths(buildSources(p))
} }
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileName() string { return b.profile.name }
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
func (b *Browser) ProfileDir() string {
if b.profile.isDefault() {
return b.profile.legacyHome
}
return filepath.Join(b.profile.container, "Safari", "Profiles", b.profile.uuidUpper)
}
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
session, err := filemanager.NewSession()
if err != nil {
return nil, err
}
defer session.Cleanup()
tempPaths := b.acquireFiles(session, categories)
data := &types.BrowserData{}
for _, cat := range categories {
// Keychain is user-scope, not per-profile — attribute only to default to avoid duplicates.
if cat == types.Password {
if b.profile.isDefault() {
b.extractCategory(data, cat, "")
}
continue
}
// Extension plists (AppExtensions + WebExtensions) live directly in the container
// and are read in-place; attribute to default only until per-profile layouts are verified.
if cat == types.Extension {
if b.profile.isDefault() {
b.extractCategory(data, cat, "")
}
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
}
b.extractCategory(data, cat, path)
}
return data, nil
}
func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]int, error) {
session, err := filemanager.NewSession()
if err != nil {
return nil, err
}
defer session.Cleanup()
tempPaths := b.acquireFiles(session, categories)
counts := make(map[types.Category]int)
for _, cat := range categories {
if cat == types.Password {
if b.profile.isDefault() {
counts[cat] = b.countCategory(cat, "")
}
continue
}
if cat == types.Extension {
if b.profile.isDefault() {
counts[cat] = b.countCategory(cat, "")
}
continue
}
path, ok := tempPaths[cat]
if !ok {
continue
}
counts[cat] = b.countCategory(cat, path)
}
return counts, nil
}
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
rp, ok := b.sourcePaths[cat]
if !ok {
continue
}
dst := filepath.Join(session.TempDir(), cat.String())
if err := session.Acquire(rp.absPath, dst, rp.isDir); err != nil {
log.Debugf("acquire %s: %v", cat, err)
continue
}
tempPaths[cat] = dst
}
return tempPaths
}
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, path string) {
var err error
switch cat {
case types.Password:
data.Passwords, err = extractPasswords(b.keychainPassword)
case types.History:
data.Histories, err = extractHistories(path)
case types.Cookie:
data.Cookies, err = extractCookies(path)
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.Download:
data.Downloads, err = extractDownloads(path, b.profile.downloadOwnerUUID())
case types.LocalStorage:
data.LocalStorage, err = extractLocalStorage(path)
case types.Extension:
data.Extensions, err = extractExtensions(b.profile.container)
default:
return
}
if err != nil {
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
}
func (b *Browser) countCategory(cat types.Category, path string) int {
var count int
var err error
switch cat {
case types.Password:
count, err = countPasswords(b.keychainPassword)
case types.History:
count, err = countHistories(path)
case types.Cookie:
count, err = countCookies(path)
case types.Bookmark:
count, err = countBookmarks(path)
case types.Download:
count, err = countDownloads(path, b.profile.downloadOwnerUUID())
case types.LocalStorage:
count, err = countLocalStorage(path)
case types.Extension:
count, err = countExtensions(b.profile.container)
default:
// Unsupported categories silently return 0.
}
if err != nil {
log.Debugf("count %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
}
return count
}
type resolvedPath struct { type resolvedPath struct {
absPath string absPath string
isDir bool isDir bool
+28 -176
View File
@@ -59,17 +59,18 @@ func TestNewBrowsers(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
dir := tt.setup(t) dir := tt.setup(t)
cfg := types.BrowserConfig{Name: "Safari", Kind: types.Safari, UserDataDir: dir} cfg := types.BrowserConfig{Name: "Safari", Kind: types.Safari, UserDataDir: dir}
browsers, err := NewBrowsers(cfg) b, err := NewBrowser(cfg)
require.NoError(t, err) require.NoError(t, err)
if tt.wantLen == 0 { if tt.wantLen == 0 {
assert.Empty(t, browsers) assert.Nil(t, b)
return return
} }
require.Len(t, browsers, tt.wantLen) require.NotNil(t, b)
assert.Equal(t, "Safari", browsers[0].BrowserName()) assert.Equal(t, "Safari", b.BrowserName())
assert.Equal(t, "default", browsers[0].ProfileName()) require.Len(t, b.Profiles(), 1)
assert.Equal(t, dir, browsers[0].ProfileDir()) assert.Equal(t, "default", b.Profiles()[0].Name)
assert.Equal(t, dir, b.Profiles()[0].Dir)
}) })
} }
} }
@@ -106,32 +107,33 @@ func TestNewBrowsers_MultiProfile(t *testing.T) {
}) })
cfg := types.BrowserConfig{Name: "Safari", Kind: types.Safari, UserDataDir: legacyHome} cfg := types.BrowserConfig{Name: "Safari", Kind: types.Safari, UserDataDir: legacyHome}
browsers, err := NewBrowsers(cfg) b, err := NewBrowser(cfg)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 2) require.NotNil(t, b)
require.Len(t, b.profiles, 2)
names := []string{browsers[0].ProfileName(), browsers[1].ProfileName()} names := []string{b.profiles[0].ctx.name, b.profiles[1].ctx.name}
assert.Contains(t, names, "default") assert.Contains(t, names, "default")
assert.Contains(t, names, "work") assert.Contains(t, names, "work")
for _, b := range browsers { for _, p := range b.profiles {
switch b.ProfileName() { switch p.ctx.name {
case "default": case "default":
assert.Equal(t, legacyHome, b.ProfileDir()) assert.Equal(t, legacyHome, p.dir())
assert.Contains(t, b.sourcePaths, types.History) assert.Contains(t, p.sourcePaths, types.History)
assert.Equal(t, filepath.Join(legacyHome, "History.db"), b.sourcePaths[types.History].absPath) assert.Equal(t, filepath.Join(legacyHome, "History.db"), p.sourcePaths[types.History].absPath)
// Default profile's LocalStorage root (WebsiteData/Default) isn't created in this fixture, // Default profile's LocalStorage root (WebsiteData/Default) isn't created in this fixture,
// so it won't resolve — which is the point: resolveSourcePaths only registers paths that exist. // so it won't resolve — which is the point: resolveSourcePaths only registers paths that exist.
assert.NotContains(t, b.sourcePaths, types.LocalStorage) assert.NotContains(t, p.sourcePaths, types.LocalStorage)
case "work": case "work":
assert.Equal(t, filepath.Join(container, "Safari", "Profiles", uuid), b.ProfileDir()) assert.Equal(t, filepath.Join(container, "Safari", "Profiles", uuid), p.dir())
assert.Contains(t, b.sourcePaths, types.History) assert.Contains(t, p.sourcePaths, types.History)
assert.Equal(t, assert.Equal(t,
filepath.Join(container, "Safari", "Profiles", uuid, "History.db"), filepath.Join(container, "Safari", "Profiles", uuid, "History.db"),
b.sourcePaths[types.History].absPath) p.sourcePaths[types.History].absPath)
require.Contains(t, b.sourcePaths, types.LocalStorage) require.Contains(t, p.sourcePaths, types.LocalStorage)
assert.Equal(t, namedOriginsDir, b.sourcePaths[types.LocalStorage].absPath) assert.Equal(t, namedOriginsDir, p.sourcePaths[types.LocalStorage].absPath)
assert.True(t, b.sourcePaths[types.LocalStorage].isDir) assert.True(t, p.sourcePaths[types.LocalStorage].isDir)
} }
} }
} }
@@ -174,166 +176,16 @@ func TestCountEntries(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "History.db"), data, 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "History.db"), data, 0o644))
browsers, err := NewBrowsers(types.BrowserConfig{ b, err := NewBrowser(types.BrowserConfig{
Name: "Safari", Kind: types.Safari, UserDataDir: dir, Name: "Safari", Kind: types.Safari, UserDataDir: dir,
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, browsers, 1) require.NotNil(t, b)
counts, err := browsers[0].CountEntries([]types.Category{types.History}) results, err := b.CountEntries([]types.Category{types.History})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 2, counts[types.History]) require.Len(t, results, 1)
} assert.Equal(t, 2, results[0].Counts[types.History])
// ---------------------------------------------------------------------------
// countCategory / extractCategory
// ---------------------------------------------------------------------------
func TestCountCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "History.db",
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
insertHistoryItem(1, "https://example.com", "example.com", 1),
)
b := &Browser{}
assert.Equal(t, 1, b.countCategory(types.History, path))
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{domain: ".example.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
{domain: ".go.dev", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
})
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
})
t.Run("Bookmark", func(t *testing.T) {
path := buildTestBookmarksPlist(t, safariBookmark{
Type: bookmarkTypeList,
Children: []safariBookmark{
{Type: bookmarkTypeLeaf, URLString: "https://a.com", URIDictionary: uriDictionary{Title: "A"}},
{Type: bookmarkTypeLeaf, URLString: "https://b.com", URIDictionary: uriDictionary{Title: "B"}},
},
})
b := &Browser{}
assert.Equal(t, 2, b.countCategory(types.Bookmark, path))
})
t.Run("Download", func(t *testing.T) {
path := buildTestDownloadsPlist(t, safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 100},
},
})
b := &Browser{}
assert.Equal(t, 1, b.countCategory(types.Download, path))
})
t.Run("LocalStorage", func(t *testing.T) {
dir := buildTestLocalStorageDir(t, map[string][]testLocalStorageItem{
"https://example.com": {{Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}},
"https://go.dev": {{Key: "theme", Value: "dark"}},
})
b := &Browser{}
assert.Equal(t, 3, b.countCategory(types.LocalStorage, dir))
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused"))
assert.Equal(t, 0, b.countCategory(types.SessionStorage, "unused"))
})
}
func TestExtractCategory(t *testing.T) {
t.Run("History", func(t *testing.T) {
path := createTestDB(t, "History.db",
[]string{safariHistoryItemsSchema, safariHistoryVisitsSchema},
insertHistoryItem(1, "https://example.com", "example.com", 3),
insertHistoryItem(2, "https://go.dev", "go.dev", 1),
insertHistoryVisit(1, 1, 700000000.0, "Example"),
insertHistoryVisit(2, 2, 700000000.0, "Go"),
)
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.History, path)
require.Len(t, data.Histories, 2)
// Sorted by visit count descending
assert.Equal(t, 3, data.Histories[0].VisitCount)
assert.Equal(t, 1, data.Histories[1].VisitCount)
})
t.Run("Cookie", func(t *testing.T) {
path := buildTestBinaryCookies(t, []testCookie{
{
domain: ".example.com", name: "session", path: "/", value: "abc",
secure: true, httpOnly: true, expires: 2000000000.0, creation: 700000000.0,
},
})
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Cookie, path)
require.Len(t, data.Cookies, 1)
assert.Equal(t, ".example.com", data.Cookies[0].Host)
assert.Equal(t, "session", data.Cookies[0].Name)
assert.True(t, data.Cookies[0].IsSecure)
assert.True(t, data.Cookies[0].IsHTTPOnly)
})
t.Run("Bookmark", func(t *testing.T) {
path := buildTestBookmarksPlist(t, safariBookmark{
Type: bookmarkTypeList,
Children: []safariBookmark{
{Type: bookmarkTypeLeaf, URLString: "https://github.com", URIDictionary: uriDictionary{Title: "GitHub"}},
},
})
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Bookmark, path)
require.Len(t, data.Bookmarks, 1)
assert.Equal(t, "GitHub", data.Bookmarks[0].Name)
assert.Equal(t, "https://github.com", data.Bookmarks[0].URL)
})
t.Run("Download", func(t *testing.T) {
path := buildTestDownloadsPlist(t, safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 1024},
},
})
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.Download, path)
require.Len(t, data.Downloads, 1)
assert.Equal(t, "https://example.com/file.zip", data.Downloads[0].URL)
assert.Equal(t, int64(1024), data.Downloads[0].TotalBytes)
})
t.Run("LocalStorage", func(t *testing.T) {
dir := buildTestLocalStorageDir(t, map[string][]testLocalStorageItem{
"https://github.com": {{Key: "theme", Value: "dark"}},
})
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.LocalStorage, dir)
require.Len(t, data.LocalStorage, 1)
assert.Equal(t, "https://github.com", data.LocalStorage[0].URL)
assert.Equal(t, "theme", data.LocalStorage[0].Key)
assert.Equal(t, "dark", data.LocalStorage[0].Value)
})
t.Run("UnsupportedCategory", func(t *testing.T) {
b := &Browser{}
data := &types.BrowserData{}
b.extractCategory(data, types.CreditCard, "unused")
assert.Empty(t, data.CreditCards)
})
} }
// Anchor: 2024-01-15T10:30:00Z, in seconds past the Core Data epoch (2001-01-01Z). // Anchor: 2024-01-15T10:30:00Z, in seconds past the Core Data epoch (2001-01-01Z).
+1 -1
View File
@@ -31,7 +31,7 @@ func dumpCmd() *cobra.Command {
hack-browser-data dump -f cookie-editor hack-browser-data dump -f cookie-editor
hack-browser-data dump --zip`, hack-browser-data dump --zip`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.PickBrowsers(browser.PickOptions{ browsers, err := browser.DiscoverBrowsersWithKeys(browser.PickOptions{
Name: browserName, Name: browserName,
ProfilePath: profilePath, ProfilePath: profilePath,
KeychainPassword: keychainPw, KeychainPassword: keychainPw,
+6 -4
View File
@@ -17,12 +17,14 @@ func extractAndWrite(browsers []browser.Browser, categories []types.Category, ou
return err return err
} }
for _, b := range browsers { for _, b := range browsers {
log.Infof("Extracting %s/%s...", b.BrowserName(), b.ProfileName()) log.Infof("Extracting %s...", b.BrowserName())
data, extractErr := b.Extract(categories) results, extractErr := b.Extract(categories)
if extractErr != nil { if extractErr != nil {
log.Errorf("extract %s/%s: %v", b.BrowserName(), b.ProfileName(), extractErr) log.Errorf("extract %s: %v", b.BrowserName(), extractErr)
}
for _, r := range results {
w.Add(b.BrowserName(), r.Name, r.Data)
} }
w.Add(b.BrowserName(), b.ProfileName(), data)
} }
if err := w.Write(); err != nil { if err := w.Write(); err != nil {
return err return err
+1 -1
View File
@@ -35,7 +35,7 @@ func keysExportCmd() *cobra.Command {
Example: ` hack-browser-data keys export -o dump.json Example: ` hack-browser-data keys export -o dump.json
hack-browser-data keys export -b chrome`, hack-browser-data keys export -b chrome`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
browsers, err := browser.PickBrowsers(browser.PickOptions{ browsers, err := browser.DiscoverBrowsersWithKeys(browser.PickOptions{
Name: browserName, Name: browserName,
KeychainPassword: keychainPw, KeychainPassword: keychainPw,
}) })
+10 -6
View File
@@ -43,7 +43,9 @@ func printBasic(out io.Writer, browsers []browser.Browser) error {
w := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) w := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "Browser\tProfile\tPath") fmt.Fprintln(w, "Browser\tProfile\tPath")
for _, b := range browsers { for _, b := range browsers {
fmt.Fprintf(w, "%s\t%s\t%s\n", b.BrowserName(), b.ProfileName(), b.ProfileDir()) for _, p := range b.Profiles() {
fmt.Fprintf(w, "%s\t%s\t%s\n", b.BrowserName(), p.Name, p.Dir)
}
} }
return w.Flush() return w.Flush()
} }
@@ -58,12 +60,14 @@ func printDetail(out io.Writer, browsers []browser.Browser) error {
fmt.Fprintln(w) fmt.Fprintln(w)
for _, b := range browsers { for _, b := range browsers {
counts, _ := b.CountEntries(types.AllCategories) results, _ := b.CountEntries(types.AllCategories)
fmt.Fprintf(w, "%s\t%s", b.BrowserName(), b.ProfileName()) for _, r := range results {
for _, c := range types.AllCategories { fmt.Fprintf(w, "%s\t%s", b.BrowserName(), r.Name)
fmt.Fprintf(w, "\t%d", counts[c]) for _, c := range types.AllCategories {
fmt.Fprintf(w, "\t%d", r.Counts[c])
}
fmt.Fprintln(w)
} }
fmt.Fprintln(w)
} }
return w.Flush() return w.Flush()
} }
+7 -7
View File
@@ -13,14 +13,14 @@ Key constraints:
- **Go 1.20** — the module must build with Go 1.20 to maintain Windows 7 support. Features from Go 1.21+ (`log/slog`, `slices`, `maps`, `cmp`) must not be used. - **Go 1.20** — the module must build with Go 1.20 to maintain Windows 7 support. Features from Go 1.21+ (`log/slog`, `slices`, `maps`, `cmp`) must not be used.
- **Supported engines**: Chromium (including Yandex and Opera variants) and Firefox. - **Supported engines**: Chromium (including Yandex and Opera variants) and Firefox.
- **Supported platforms**: Windows (DPAPI), macOS (Keychain), Linux (D-Bus Secret Service). - **Supported platforms**: Windows (DPAPI), macOS (Keychain), Linux (D-Bus Secret Service).
- **No root-level library API** — the CLI calls `browser.PickBrowsers()` directly; there is no importable `pkg/` surface. - **No root-level library API** — the CLI calls `browser.DiscoverBrowsersWithKeys()` directly; there is no importable `pkg/` surface.
## 2. Directory Structure ## 2. Directory Structure
``` ```
HackBrowserData/ HackBrowserData/
├── cmd/hack-browser-data/ # CLI entrypoint: cobra root, dump, list, version ├── cmd/hack-browser-data/ # CLI entrypoint: cobra root, dump, list, version
├── browser/ # Browser interface, PickBrowsers(), platform browser lists ├── browser/ # Browser interface, DiscoverBrowsersWithKeys(), platform browser lists
│ ├── chromium/ # Chromium engine: extraction, decryption, profile discovery │ ├── chromium/ # Chromium engine: extraction, decryption, profile discovery
│ └── firefox/ # Firefox engine: extraction, NSS key derivation │ └── firefox/ # Firefox engine: extraction, NSS key derivation
├── types/ # Data model: Category enum, Entry structs, BrowserData ├── types/ # Data model: Category enum, Entry structs, BrowserData
@@ -82,14 +82,14 @@ See `types/category.go` for the authoritative enum definition.
There are two entry points, one for extraction and one for discovery: There are two entry points, one for extraction and one for discovery:
``` ```
PickBrowsers(opts) // used by `dump` — ready to Extract DiscoverBrowsersWithKeys(opts) // used by `dump` — ready to Extract
→ pickFromConfigs(configs, opts) // shared discovery core → pickFromConfigs(configs, opts) // shared discovery core
→ platformBrowsers() // build-tagged list for this OS → platformBrowsers() // build-tagged list for this OS
→ filter by name / profile path → filter by name / profile path
→ newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers → newBrowsers(cfg) // dispatch to chromium/firefox/safari.NewBrowsers
→ discoverProfiles() // scan profile subdirectories → discoverProfiles() // scan profile subdirectories
→ resolveSourcePaths() // stat candidates, first match wins → resolveSourcePaths() // stat candidates, first match wins
→ newPlatformInjector(opts) // build-tagged: returns a func(Browser) → newCredentialInjector(opts) // build-tagged: returns a browserInjector
→ for each browser: // closure captures retriever + keychain pw lazily → for each browser: // closure captures retriever + keychain pw lazily
inject(b) // type-assert retrieverSetter / keychainPasswordSetter inject(b) // type-assert retrieverSetter / keychainPasswordSetter
@@ -97,7 +97,7 @@ DiscoverBrowsers(opts) // used by `list` / `list --detail`
→ pickFromConfigs(configs, opts) // same shared discovery core, NO injection → pickFromConfigs(configs, opts) // same shared discovery core, NO injection
``` ```
`PickBrowsers` does discovery + decryption setup in one call; the returned `DiscoverBrowsersWithKeys` does discovery + decryption setup in one call; the returned
browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection browsers are ready for `b.Extract`. `DiscoverBrowsers` skips injection
entirely, so list-style commands never trigger the macOS Keychain password entirely, so list-style commands never trigger the macOS Keychain password
prompt — they have no use for the credential. Both entry points share the prompt — they have no use for the credential. Both entry points share the
@@ -106,8 +106,8 @@ consistent.
Key design decisions: Key design decisions:
- **One KeyRetriever chain per process** — built lazily inside `newPlatformInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS. - **One KeyRetriever chain per process** — built lazily inside `newCredentialInjector` and reused across every Chromium browser and every profile to prevent repeated keychain prompts on macOS.
- **Discovery is decoupled from injection** — `pickFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `PickBrowsers` continues into injection. - **Discovery is decoupled from injection** — `pickFromConfigs` is injection-free; `DiscoverBrowsers` stops after it, `DiscoverBrowsersWithKeys` continues into injection.
- **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files. - **Profile discovery differs by engine**: Chromium looks for `Preferences` files in subdirectories; Firefox accepts any subdirectory containing known source files.
- **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory. - **Flat layout fallback** — Opera-style browsers that store data directly in UserDataDir (no profile subdirectories) are handled by falling back to the base directory.
+3 -3
View File
@@ -36,7 +36,7 @@ The return value is the **ready-to-use decryption key** — either the raw AES k
`ChainRetriever` wraps multiple retrievers and tries them in order. The first successful result wins. If all fail, errors from every retriever are combined into a single error. `ChainRetriever` wraps multiple retrievers and tries them in order. The first successful result wins. If all fail, errors from every retriever are combined into a single error.
**Caching**: the retriever chain is created once per process inside `newPlatformInjector` (see `browser/browser_{darwin,linux,windows}.go`) and shared across every Chromium browser and every profile. macOS retrievers additionally use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump. **Caching**: the retriever chain is created once per process inside `newCredentialInjector` (see `browser/browser_{darwin,linux,windows}.go`) and shared across every Chromium browser and every profile. macOS retrievers additionally use `sync.Once` internally, so multi-profile browsers only trigger one keychain prompt or memory dump.
## 3. macOS Key Retrieval ## 3. macOS Key Retrieval
@@ -122,7 +122,7 @@ Windows populates two slots of the `keyretriever.Retrievers` struct — V10 (leg
| V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) | | V10 | `DPAPIRetriever` | `os_crypt.encrypted_key` | `CryptUnprotectData` (Crypt32.dll) |
| V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) | | V20 | `ABERetriever` | `os_crypt.app_bound_encrypted_key` | IElevator via reflective injection (see [RFC-010](010-chrome-abe-integration.md)) |
`browser/browser_windows.go::newPlatformInjector` calls `keyretriever.DefaultRetrievers()` and wires the resulting struct through `Browser.SetKeyRetrievers(r)`. At extract time `keyretriever.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful. `browser/browser_windows.go::newCredentialInjector` calls `keyretriever.DefaultRetrievers()` and wires the resulting struct through `Browser.SetKeyRetrievers(r)`. At extract time `keyretriever.NewMasterKeys` runs each slot independently — a failure on one tier does not prevent the other from succeeding, because mixed-tier Chrome profiles (upgraded from pre-127) need partial success to be useful.
**Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output. **Why not a ChainRetriever?** `ChainRetriever` has first-success semantics: once ABE returns a key, DPAPI is never called. That semantics is wrong for orthogonal tiers — it was the root cause of issue #578, where upgraded profiles' v10-encrypted passwords silently failed because only the v20 key was retrieved. `NewMasterKeys` evaluates each tier independently and returns an `errors.Join` of per-tier failures; log severity is a caller-side decision. `browser/chromium::getMasterKeys` currently logs all tier errors uniformly at `Warnf` — the distinction between "partial" and "total" failure was judged low-value for a short-lived CLI where all warn lines are visible in the default output.
@@ -214,7 +214,7 @@ Future contributors adding a new macOS browser that reads credentials from the K
### 7.3 Where the `--keychain-pw` Password Goes ### 7.3 Where the `--keychain-pw` Password Goes
The macOS login password is resolved once at startup by `browser/browser_darwin.go::resolveKeychainPassword`, then delivered to both consumers from within a single platform-specific closure, `newPlatformInjector` (defined per platform in `browser/browser_{darwin,linux,windows}.go`). The closure captures both the retriever chain and the raw password, and applies whichever capability interface each Browser happens to satisfy: The macOS login password is resolved once at startup by `browser/browser_darwin.go::resolveKeychainPassword`, then delivered to both consumers from within a single platform-specific closure, `newCredentialInjector` (defined per platform in `browser/browser_{darwin,linux,windows}.go`). The closure captures both the retriever chain and the raw password, and applies whichever capability interface each Browser happens to satisfy:
| Consumer | Capability interface | Defined in | Payload | | Consumer | Capability interface | Defined in | Payload |
|---|---|---|---| |---|---|---|---|
+2 -2
View File
@@ -28,7 +28,7 @@ The primary command. Extracts, decrypts, and writes browser data to files.
| `--keychain-pw` | | | macOS keychain password | | `--keychain-pw` | | | macOS keychain password |
| `--zip` | | `false` | Compress output to zip | | `--zip` | | `false` | Compress output to zip |
**Workflow**: PickBrowsers (filter by `-b`) → parseCategories (split `-c` on commas) → NewWriter (select formatter by `-f`) → Extract loop (each browser) → Write → optional CompressDir. **Workflow**: DiscoverBrowsersWithKeys (filter by `-b`) → parseCategories (split `-c` on commas) → NewWriter (select formatter by `-f`) → Extract loop (each browser) → Write → optional CompressDir.
The nine recognized categories are: `password`, `cookie`, `bookmark`, `history`, `download`, `creditcard`, `extension`, `localstorage`, `sessionstorage`. The string `"all"` maps to all nine. The nine recognized categories are: `password`, `cookie`, `bookmark`, `history`, `download`, `creditcard`, `extension`, `localstorage`, `sessionstorage`. The string `"all"` maps to all nine.
@@ -121,7 +121,7 @@ File permissions are restrictive: directories `0750`, files `0600` (data may con
``` ```
CLI: hack-browser-data dump -b chrome -c password,cookie -f csv -d results CLI: hack-browser-data dump -b chrome -c password,cookie -f csv -d results
PickBrowsers(name="chrome") → []Browser DiscoverBrowsersWithKeys(name="chrome") → []Browser
→ parseCategories("password,cookie") → []Category → parseCategories("password,cookie") → []Category
→ NewWriter("results", "csv") → *Writer → NewWriter("results", "csv") → *Writer
→ for each browser: → for each browser:
+19
View File
@@ -0,0 +1,19 @@
package types
// Profile identifies one browser profile — a leaf under an installation.
type Profile struct {
Name string
Dir string
}
// ExtractResult pairs a profile with the data extracted from it.
type ExtractResult struct {
Profile
Data *BrowserData
}
// CountResult pairs a profile with its per-category entry counts.
type CountResult struct {
Profile
Counts map[Category]int
}