Skip to content

Commit b3bbc0d

Browse files
authored
feat: add CountEntries to skip decryption for list --detail (#562)
* feat: add CountEntries to skip decryption for list --detail (#549) * test: add CountEntries and countCategory tests at browser level * fix: address review feedback on CountRows and countLocalStorage * test: add CountRows unit tests
1 parent 5f42d4f commit b3bbc0d

40 files changed

Lines changed: 1009 additions & 101 deletions

browser/browser.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Browser interface {
1919
ProfileName() string
2020
ProfileDir() string
2121
Extract(categories []types.Category) (*types.BrowserData, error)
22+
CountEntries(categories []types.Category) (map[types.Category]int, error)
2223
}
2324

2425
// PickOptions configures which browsers to pick.

browser/chromium/chromium.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,63 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
9595
return data, nil
9696
}
9797

98+
// CountEntries copies browser files to a temp directory and counts entries
99+
// per category without decryption. Much faster than Extract for display-only
100+
// use cases like "list --detail".
101+
func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]int, error) {
102+
session, err := filemanager.NewSession()
103+
if err != nil {
104+
return nil, err
105+
}
106+
defer session.Cleanup()
107+
108+
tempPaths := b.acquireFiles(session, categories)
109+
110+
counts := make(map[types.Category]int)
111+
for _, cat := range categories {
112+
path, ok := tempPaths[cat]
113+
if !ok {
114+
continue
115+
}
116+
counts[cat] = b.countCategory(cat, path)
117+
}
118+
return counts, nil
119+
}
120+
121+
// countCategory calls the appropriate count function for a category.
122+
func (b *Browser) countCategory(cat types.Category, path string) int {
123+
var count int
124+
var err error
125+
switch cat {
126+
case types.Password:
127+
count, err = countPasswords(path)
128+
case types.Cookie:
129+
count, err = countCookies(path)
130+
case types.History:
131+
count, err = countHistories(path)
132+
case types.Download:
133+
count, err = countDownloads(path)
134+
case types.Bookmark:
135+
count, err = countBookmarks(path)
136+
case types.CreditCard:
137+
count, err = countCreditCards(path)
138+
case types.Extension:
139+
if b.cfg.Kind == types.ChromiumOpera {
140+
count, err = countOperaExtensions(path)
141+
} else {
142+
count, err = countExtensions(path)
143+
}
144+
case types.LocalStorage:
145+
count, err = countLocalStorage(path)
146+
case types.SessionStorage:
147+
count, err = countSessionStorage(path)
148+
}
149+
if err != nil {
150+
log.Debugf("count %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
151+
}
152+
return count
153+
}
154+
98155
// acquireFiles copies source files to the session temp directory.
99156
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
100157
tempPaths := make(map[types.Category]string)

browser/chromium/chromium_test.go

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -544,17 +544,9 @@ func TestGetMasterKey(t *testing.T) {
544544
// ---------------------------------------------------------------------------
545545

546546
func TestExtract(t *testing.T) {
547-
// Shared fixture: profile with a real History database.
548547
dir := t.TempDir()
549548
mkFile(dir, "Default", "Preferences")
550-
551-
historyDB := createTestDB(t, "History", urlsSchema,
552-
insertURL("https://example.com", "Example", 5, 13350000000000000),
553-
)
554-
profileDir := filepath.Join(dir, "Default")
555-
data, err := os.ReadFile(historyDB)
556-
require.NoError(t, err)
557-
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "History"), data, 0o644))
549+
installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History")
558550

559551
tests := []struct {
560552
name string
@@ -586,7 +578,8 @@ func TestExtract(t *testing.T) {
586578
result, err := browsers[0].Extract([]types.Category{types.History})
587579
require.NoError(t, err)
588580
require.NotNil(t, result)
589-
require.Len(t, result.Histories, 1)
581+
require.Len(t, result.Histories, 3)
582+
// setupHistoryDB: Example(200) > GitHub(100) > Go Dev(50)
590583
assert.Equal(t, "Example", result.Histories[0].Title)
591584

592585
if tt.wantRetriever {
@@ -598,6 +591,87 @@ func TestExtract(t *testing.T) {
598591
}
599592
}
600593

594+
// ---------------------------------------------------------------------------
595+
// CountEntries
596+
// ---------------------------------------------------------------------------
597+
598+
func TestCountEntries(t *testing.T) {
599+
dir := t.TempDir()
600+
mkFile(dir, "Default", "Preferences")
601+
installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History")
602+
603+
browsers, err := NewBrowsers(types.BrowserConfig{
604+
Name: "Test", Kind: types.Chromium, UserDataDir: dir,
605+
})
606+
require.NoError(t, err)
607+
require.Len(t, browsers, 1)
608+
609+
// No retriever set — CountEntries should still work (no decryption needed).
610+
counts, err := browsers[0].CountEntries([]types.Category{types.History, types.Download})
611+
require.NoError(t, err)
612+
613+
assert.Equal(t, 3, counts[types.History])
614+
// Download uses a different table in the same file; since we only
615+
// created the urls table (not downloads), the count query will fail
616+
// gracefully and return 0.
617+
assert.Equal(t, 0, counts[types.Download])
618+
}
619+
620+
func TestCountEntries_NoRetrieverNeeded(t *testing.T) {
621+
dir := t.TempDir()
622+
mkFile(dir, "Default", "Preferences")
623+
// Login Data normally needs master key to extract, but CountEntries skips decryption.
624+
installFile(t, filepath.Join(dir, "Default"), setupLoginDB(t), "Login Data")
625+
626+
browsers, err := NewBrowsers(types.BrowserConfig{
627+
Name: "Test", Kind: types.Chromium, UserDataDir: dir,
628+
})
629+
require.NoError(t, err)
630+
require.Len(t, browsers, 1)
631+
632+
// No retriever set — CountEntries succeeds without master key.
633+
counts, err := browsers[0].CountEntries([]types.Category{types.Password})
634+
require.NoError(t, err)
635+
assert.Equal(t, 2, counts[types.Password])
636+
}
637+
638+
func TestCountCategory(t *testing.T) {
639+
t.Run("History", func(t *testing.T) {
640+
path := setupHistoryDB(t)
641+
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
642+
assert.Equal(t, 3, b.countCategory(types.History, path))
643+
})
644+
645+
t.Run("Cookie", func(t *testing.T) {
646+
path := setupCookieDB(t)
647+
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
648+
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
649+
})
650+
651+
t.Run("Bookmark", func(t *testing.T) {
652+
path := setupBookmarkJSON(t)
653+
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
654+
assert.Equal(t, 3, b.countCategory(types.Bookmark, path))
655+
})
656+
657+
t.Run("Extension_Opera", func(t *testing.T) {
658+
path := createTestJSON(t, "Secure Preferences", `{
659+
"extensions": {
660+
"opsettings": {
661+
"ext1": {"location": 1, "manifest": {"name": "Ext", "version": "1.0"}}
662+
}
663+
}
664+
}`)
665+
b := &Browser{cfg: types.BrowserConfig{Kind: types.ChromiumOpera}}
666+
assert.Equal(t, 1, b.countCategory(types.Extension, path))
667+
})
668+
669+
t.Run("FileNotFound", func(t *testing.T) {
670+
b := &Browser{cfg: types.BrowserConfig{Kind: types.Chromium}}
671+
assert.Equal(t, 0, b.countCategory(types.History, "/nonexistent/path"))
672+
})
673+
}
674+
601675
// ---------------------------------------------------------------------------
602676
// SetRetriever: verify *Browser satisfies the interface used by
603677
// browser.pickFromConfigs for post-construction retriever injection.

browser/chromium/extract_bookmark.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,32 @@ func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry)
5151
walkBookmarks(child, currentFolder, out)
5252
}
5353
}
54+
55+
func countBookmarks(path string) (int, error) {
56+
data, err := os.ReadFile(path)
57+
if err != nil {
58+
return 0, err
59+
}
60+
var count int
61+
roots := gjson.GetBytes(data, "roots")
62+
roots.ForEach(func(_, value gjson.Result) bool {
63+
count += walkCountBookmarks(value)
64+
return true
65+
})
66+
return count, nil
67+
}
68+
69+
// walkCountBookmarks recursively counts URL nodes in the bookmark tree.
70+
func walkCountBookmarks(node gjson.Result) int {
71+
count := 0
72+
if node.Get("type").String() == "url" {
73+
count++
74+
}
75+
children := node.Get("children")
76+
if children.Exists() && children.IsArray() {
77+
for _, child := range children.Array() {
78+
count += walkCountBookmarks(child)
79+
}
80+
}
81+
return count
82+
}

browser/chromium/extract_bookmark_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import (
77
"github.com/stretchr/testify/require"
88
)
99

10-
func TestExtractBookmarks(t *testing.T) {
11-
path := createTestJSON(t, "Bookmarks", `{
10+
func setupBookmarkJSON(t *testing.T) string {
11+
t.Helper()
12+
return createTestJSON(t, "Bookmarks", `{
1213
"roots": {
1314
"bookmark_bar": {
1415
"name": "Bookmarks Bar",
@@ -33,6 +34,10 @@ func TestExtractBookmarks(t *testing.T) {
3334
}
3435
}
3536
}`)
37+
}
38+
39+
func TestExtractBookmarks(t *testing.T) {
40+
path := setupBookmarkJSON(t)
3641

3742
got, err := extractBookmarks(path)
3843
require.NoError(t, err)
@@ -52,6 +57,22 @@ func TestExtractBookmarks(t *testing.T) {
5257
assert.Equal(t, "News", got[2].Folder) // parent folder name
5358
}
5459

60+
func TestCountBookmarks(t *testing.T) {
61+
path := setupBookmarkJSON(t)
62+
63+
count, err := countBookmarks(path)
64+
require.NoError(t, err)
65+
assert.Equal(t, 3, count) // 3 URLs, folders not counted
66+
}
67+
68+
func TestCountBookmarks_Empty(t *testing.T) {
69+
path := createTestJSON(t, "Bookmarks", `{"roots": {}}`)
70+
71+
count, err := countBookmarks(path)
72+
require.NoError(t, err)
73+
assert.Equal(t, 0, count)
74+
}
75+
5576
func TestExtractBookmarks_FoldersExcluded(t *testing.T) {
5677
path := createTestJSON(t, "Bookmarks", `{
5778
"roots": {

browser/chromium/extract_cookie.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import (
1111
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
1212
)
1313

14-
const defaultCookieQuery = `SELECT name, encrypted_value, host_key, path,
15-
creation_utc, expires_utc, is_secure, is_httponly,
16-
has_expires, is_persistent FROM cookies`
14+
const (
15+
defaultCookieQuery = `SELECT name, encrypted_value, host_key, path,
16+
creation_utc, expires_utc, is_secure, is_httponly,
17+
has_expires, is_persistent FROM cookies`
18+
countCookieQuery = `SELECT COUNT(*) FROM cookies`
19+
)
1720

1821
func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) {
1922
var decryptFails int
@@ -65,6 +68,10 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error)
6568
return cookies, nil
6669
}
6770

71+
func countCookies(path string) (int, error) {
72+
return sqliteutil.CountRows(path, false, countCookieQuery)
73+
}
74+
6875
// stripCookieHash removes the SHA256(host_key) prefix from a decrypted cookie value.
6976
// Chrome 130+ (Cookie DB schema version 24) prepends SHA256(domain) to the cookie
7077
// value before encryption to prevent cross-domain cookie replay attacks.

browser/chromium/extract_cookie_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ import (
88
"github.com/stretchr/testify/require"
99
)
1010

11-
func TestExtractCookies(t *testing.T) {
12-
path := createTestDB(t, "Cookies", cookiesSchema,
11+
func setupCookieDB(t *testing.T) string {
12+
t.Helper()
13+
return createTestDB(t, "Cookies", cookiesSchema,
1314
insertCookie("session", ".old.com", "/", "", 13340000000000000, 13350000000000000, 1, 1),
1415
insertCookie("token", ".new.com", "/api", "", 13360000000000000, 13370000000000000, 1, 0),
1516
)
17+
}
18+
19+
func TestExtractCookies(t *testing.T) {
20+
path := setupCookieDB(t)
1621

1722
got, err := extractCookies(nil, path)
1823
require.NoError(t, err)
@@ -34,6 +39,22 @@ func TestExtractCookies(t *testing.T) {
3439
assert.True(t, got[1].IsHTTPOnly)
3540
}
3641

42+
func TestCountCookies(t *testing.T) {
43+
path := setupCookieDB(t)
44+
45+
count, err := countCookies(path)
46+
require.NoError(t, err)
47+
assert.Equal(t, 2, count)
48+
}
49+
50+
func TestCountCookies_Empty(t *testing.T) {
51+
path := createTestDB(t, "Cookies", cookiesSchema)
52+
53+
count, err := countCookies(path)
54+
require.NoError(t, err)
55+
assert.Equal(t, 0, count)
56+
}
57+
3758
func TestStripCookieHash(t *testing.T) {
3859
googleHash := sha256.Sum256([]byte(".google.com"))
3960
shopifyHash := sha256.Sum256([]byte(".shopify.com"))

browser/chromium/extract_creditcard.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import (
88
"github.com/moond4rk/hackbrowserdata/utils/sqliteutil"
99
)
1010

11-
const defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year,
12-
card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards`
11+
const (
12+
defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year,
13+
card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards`
14+
countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards`
15+
)
1316

1417
func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) {
1518
var decryptFails int
@@ -44,3 +47,7 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry,
4447
}
4548
return cards, nil
4649
}
50+
51+
func countCreditCards(path string) (int, error) {
52+
return sqliteutil.CountRows(path, false, countCreditCardQuery)
53+
}

browser/chromium/extract_creditcard_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import (
77
"github.com/stretchr/testify/require"
88
)
99

10-
func TestExtractCreditCards(t *testing.T) {
11-
path := createTestDB(t, "Web Data", creditCardsSchema,
10+
func setupCreditCardDB(t *testing.T) string {
11+
t.Helper()
12+
return createTestDB(t, "Web Data", creditCardsSchema,
1213
insertCreditCard("John Doe", 12, 2025, "", "Johnny", "addr-1"),
1314
insertCreditCard("Jane Smith", 6, 2027, "", "", ""),
1415
)
16+
}
17+
18+
func TestExtractCreditCards(t *testing.T) {
19+
path := setupCreditCardDB(t)
1520

1621
got, err := extractCreditCards(nil, path)
1722
require.NoError(t, err)
@@ -28,3 +33,19 @@ func TestExtractCreditCards(t *testing.T) {
2833
assert.Equal(t, "6", got[1].ExpMonth)
2934
assert.Equal(t, "2027", got[1].ExpYear)
3035
}
36+
37+
func TestCountCreditCards(t *testing.T) {
38+
path := setupCreditCardDB(t)
39+
40+
count, err := countCreditCards(path)
41+
require.NoError(t, err)
42+
assert.Equal(t, 2, count)
43+
}
44+
45+
func TestCountCreditCards_Empty(t *testing.T) {
46+
path := createTestDB(t, "Web Data", creditCardsSchema)
47+
48+
count, err := countCreditCards(path)
49+
require.NoError(t, err)
50+
assert.Equal(t, 0, count)
51+
}

0 commit comments

Comments
 (0)