From b3bbc0dadfe64ebfc3fc3ec58b6e9c65793b6977 Mon Sep 17 00:00:00 2001 From: Roger Date: Tue, 7 Apr 2026 22:28:39 +0800 Subject: [PATCH] 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 --- browser/browser.go | 1 + browser/chromium/chromium.go | 57 +++++++++++++ browser/chromium/chromium_test.go | 94 ++++++++++++++++++--- browser/chromium/extract_bookmark.go | 29 +++++++ browser/chromium/extract_bookmark_test.go | 25 +++++- browser/chromium/extract_cookie.go | 13 ++- browser/chromium/extract_cookie_test.go | 25 +++++- browser/chromium/extract_creditcard.go | 11 ++- browser/chromium/extract_creditcard_test.go | 25 +++++- browser/chromium/extract_download.go | 11 ++- browser/chromium/extract_download_test.go | 25 +++++- browser/chromium/extract_extension.go | 42 +++++++++ browser/chromium/extract_extension_test.go | 48 ++++++++++- browser/chromium/extract_history.go | 9 +- browser/chromium/extract_history_test.go | 25 +++++- browser/chromium/extract_password.go | 9 +- browser/chromium/extract_password_test.go | 25 +++++- browser/chromium/extract_storage.go | 47 +++++++++++ browser/chromium/extract_storage_test.go | 68 ++++++++++++--- browser/chromium/testutil_test.go | 10 +++ browser/firefox/extract_bookmark.go | 12 ++- browser/firefox/extract_bookmark_test.go | 26 +++++- browser/firefox/extract_cookie.go | 11 ++- browser/firefox/extract_cookie_test.go | 25 +++++- browser/firefox/extract_download.go | 16 +++- browser/firefox/extract_download_test.go | 26 +++++- browser/firefox/extract_extension.go | 15 ++++ browser/firefox/extract_extension_test.go | 25 +++++- browser/firefox/extract_history.go | 11 ++- browser/firefox/extract_history_test.go | 25 +++++- browser/firefox/extract_password.go | 8 ++ browser/firefox/extract_password_test.go | 23 +++++ browser/firefox/extract_storage.go | 9 +- browser/firefox/extract_storage_test.go | 25 +++++- browser/firefox/firefox.go | 51 +++++++++++ browser/firefox/firefox_test.go | 58 +++++++++++++ browser/firefox/testutil_test.go | 8 ++ cmd/hack-browser-data/list.go | 32 +------ utils/sqliteutil/query.go | 32 ++++++- utils/sqliteutil/sqlite_test.go | 73 ++++++++++++++++ 40 files changed, 1009 insertions(+), 101 deletions(-) diff --git a/browser/browser.go b/browser/browser.go index ad496b2..3326a74 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -19,6 +19,7 @@ type Browser interface { ProfileName() string ProfileDir() string Extract(categories []types.Category) (*types.BrowserData, error) + CountEntries(categories []types.Category) (map[types.Category]int, error) } // PickOptions configures which browsers to pick. diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index fe21048..e623cf4 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -95,6 +95,63 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro 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: + 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) diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 113cf95..f567bca 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -544,17 +544,9 @@ func TestGetMasterKey(t *testing.T) { // --------------------------------------------------------------------------- func TestExtract(t *testing.T) { - // Shared fixture: profile with a real History database. dir := t.TempDir() mkFile(dir, "Default", "Preferences") - - historyDB := createTestDB(t, "History", urlsSchema, - insertURL("https://example.com", "Example", 5, 13350000000000000), - ) - profileDir := filepath.Join(dir, "Default") - data, err := os.ReadFile(historyDB) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(profileDir, "History"), data, 0o644)) + installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History") tests := []struct { name string @@ -586,7 +578,8 @@ func TestExtract(t *testing.T) { result, err := browsers[0].Extract([]types.Category{types.History}) require.NoError(t, err) require.NotNil(t, result) - require.Len(t, result.Histories, 1) + require.Len(t, result.Histories, 3) + // setupHistoryDB: Example(200) > GitHub(100) > Go Dev(50) assert.Equal(t, "Example", result.Histories[0].Title) if tt.wantRetriever { @@ -598,6 +591,87 @@ func TestExtract(t *testing.T) { } } +// --------------------------------------------------------------------------- +// CountEntries +// --------------------------------------------------------------------------- + +func TestCountEntries(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "Default", "Preferences") + installFile(t, filepath.Join(dir, "Default"), setupHistoryDB(t), "History") + + browsers, err := NewBrowsers(types.BrowserConfig{ + Name: "Test", Kind: types.Chromium, UserDataDir: dir, + }) + require.NoError(t, err) + require.Len(t, browsers, 1) + + // No retriever set — CountEntries should still work (no decryption needed). + counts, err := browsers[0].CountEntries([]types.Category{types.History, types.Download}) + require.NoError(t, err) + + assert.Equal(t, 3, counts[types.History]) + // Download uses a different table in the same file; since we only + // created the urls table (not downloads), the count query will fail + // gracefully and return 0. + assert.Equal(t, 0, counts[types.Download]) +} + +func TestCountEntries_NoRetrieverNeeded(t *testing.T) { + dir := t.TempDir() + mkFile(dir, "Default", "Preferences") + // Login Data normally needs master key to extract, but CountEntries skips decryption. + installFile(t, filepath.Join(dir, "Default"), setupLoginDB(t), "Login Data") + + browsers, err := NewBrowsers(types.BrowserConfig{ + Name: "Test", Kind: types.Chromium, UserDataDir: dir, + }) + require.NoError(t, err) + require.Len(t, browsers, 1) + + // No retriever set — CountEntries succeeds without master key. + counts, err := browsers[0].CountEntries([]types.Category{types.Password}) + require.NoError(t, err) + assert.Equal(t, 2, 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")) + }) +} + // --------------------------------------------------------------------------- // SetRetriever: verify *Browser satisfies the interface used by // browser.pickFromConfigs for post-construction retriever injection. diff --git a/browser/chromium/extract_bookmark.go b/browser/chromium/extract_bookmark.go index 2894612..7abd8a0 100644 --- a/browser/chromium/extract_bookmark.go +++ b/browser/chromium/extract_bookmark.go @@ -51,3 +51,32 @@ func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry) walkBookmarks(child, currentFolder, out) } } + +func countBookmarks(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + var count int + roots := gjson.GetBytes(data, "roots") + roots.ForEach(func(_, value gjson.Result) bool { + count += walkCountBookmarks(value) + return true + }) + return count, nil +} + +// walkCountBookmarks recursively counts URL nodes in the bookmark tree. +func walkCountBookmarks(node gjson.Result) int { + count := 0 + if node.Get("type").String() == "url" { + count++ + } + children := node.Get("children") + if children.Exists() && children.IsArray() { + for _, child := range children.Array() { + count += walkCountBookmarks(child) + } + } + return count +} diff --git a/browser/chromium/extract_bookmark_test.go b/browser/chromium/extract_bookmark_test.go index 01a2f08..1850fcd 100644 --- a/browser/chromium/extract_bookmark_test.go +++ b/browser/chromium/extract_bookmark_test.go @@ -7,8 +7,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractBookmarks(t *testing.T) { - path := createTestJSON(t, "Bookmarks", `{ +func setupBookmarkJSON(t *testing.T) string { + t.Helper() + return createTestJSON(t, "Bookmarks", `{ "roots": { "bookmark_bar": { "name": "Bookmarks Bar", @@ -33,6 +34,10 @@ func TestExtractBookmarks(t *testing.T) { } } }`) +} + +func TestExtractBookmarks(t *testing.T) { + path := setupBookmarkJSON(t) got, err := extractBookmarks(path) require.NoError(t, err) @@ -52,6 +57,22 @@ func TestExtractBookmarks(t *testing.T) { assert.Equal(t, "News", got[2].Folder) // parent folder name } +func TestCountBookmarks(t *testing.T) { + path := setupBookmarkJSON(t) + + count, err := countBookmarks(path) + require.NoError(t, err) + assert.Equal(t, 3, count) // 3 URLs, folders not counted +} + +func TestCountBookmarks_Empty(t *testing.T) { + path := createTestJSON(t, "Bookmarks", `{"roots": {}}`) + + count, err := countBookmarks(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractBookmarks_FoldersExcluded(t *testing.T) { path := createTestJSON(t, "Bookmarks", `{ "roots": { diff --git a/browser/chromium/extract_cookie.go b/browser/chromium/extract_cookie.go index 8e7f40b..5ede706 100644 --- a/browser/chromium/extract_cookie.go +++ b/browser/chromium/extract_cookie.go @@ -11,9 +11,12 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const defaultCookieQuery = `SELECT name, encrypted_value, host_key, path, - creation_utc, expires_utc, is_secure, is_httponly, - has_expires, is_persistent FROM cookies` +const ( + defaultCookieQuery = `SELECT name, encrypted_value, host_key, path, + creation_utc, expires_utc, is_secure, is_httponly, + has_expires, is_persistent FROM cookies` + countCookieQuery = `SELECT COUNT(*) FROM cookies` +) func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) { var decryptFails int @@ -65,6 +68,10 @@ func extractCookies(masterKey []byte, path string) ([]types.CookieEntry, error) return cookies, nil } +func countCookies(path string) (int, error) { + return sqliteutil.CountRows(path, false, countCookieQuery) +} + // stripCookieHash removes the SHA256(host_key) prefix from a decrypted cookie value. // Chrome 130+ (Cookie DB schema version 24) prepends SHA256(domain) to the cookie // value before encryption to prevent cross-domain cookie replay attacks. diff --git a/browser/chromium/extract_cookie_test.go b/browser/chromium/extract_cookie_test.go index c9a04b3..18876ed 100644 --- a/browser/chromium/extract_cookie_test.go +++ b/browser/chromium/extract_cookie_test.go @@ -8,11 +8,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractCookies(t *testing.T) { - path := createTestDB(t, "Cookies", cookiesSchema, +func setupCookieDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "Cookies", cookiesSchema, insertCookie("session", ".old.com", "/", "", 13340000000000000, 13350000000000000, 1, 1), insertCookie("token", ".new.com", "/api", "", 13360000000000000, 13370000000000000, 1, 0), ) +} + +func TestExtractCookies(t *testing.T) { + path := setupCookieDB(t) got, err := extractCookies(nil, path) require.NoError(t, err) @@ -34,6 +39,22 @@ func TestExtractCookies(t *testing.T) { assert.True(t, got[1].IsHTTPOnly) } +func TestCountCookies(t *testing.T) { + path := setupCookieDB(t) + + count, err := countCookies(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountCookies_Empty(t *testing.T) { + path := createTestDB(t, "Cookies", cookiesSchema) + + count, err := countCookies(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestStripCookieHash(t *testing.T) { googleHash := sha256.Sum256([]byte(".google.com")) shopifyHash := sha256.Sum256([]byte(".shopify.com")) diff --git a/browser/chromium/extract_creditcard.go b/browser/chromium/extract_creditcard.go index 6babbcd..f4c3823 100644 --- a/browser/chromium/extract_creditcard.go +++ b/browser/chromium/extract_creditcard.go @@ -8,8 +8,11 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year, - card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards` +const ( + defaultCreditCardQuery = `SELECT COALESCE(guid, ''), name_on_card, expiration_month, expiration_year, + card_number_encrypted, COALESCE(nickname, ''), COALESCE(billing_address_id, '') FROM credit_cards` + countCreditCardQuery = `SELECT COUNT(*) FROM credit_cards` +) func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, error) { var decryptFails int @@ -44,3 +47,7 @@ func extractCreditCards(masterKey []byte, path string) ([]types.CreditCardEntry, } return cards, nil } + +func countCreditCards(path string) (int, error) { + return sqliteutil.CountRows(path, false, countCreditCardQuery) +} diff --git a/browser/chromium/extract_creditcard_test.go b/browser/chromium/extract_creditcard_test.go index 61b2719..d09b09a 100644 --- a/browser/chromium/extract_creditcard_test.go +++ b/browser/chromium/extract_creditcard_test.go @@ -7,11 +7,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractCreditCards(t *testing.T) { - path := createTestDB(t, "Web Data", creditCardsSchema, +func setupCreditCardDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "Web Data", creditCardsSchema, insertCreditCard("John Doe", 12, 2025, "", "Johnny", "addr-1"), insertCreditCard("Jane Smith", 6, 2027, "", "", ""), ) +} + +func TestExtractCreditCards(t *testing.T) { + path := setupCreditCardDB(t) got, err := extractCreditCards(nil, path) require.NoError(t, err) @@ -28,3 +33,19 @@ func TestExtractCreditCards(t *testing.T) { assert.Equal(t, "6", got[1].ExpMonth) assert.Equal(t, "2027", got[1].ExpYear) } + +func TestCountCreditCards(t *testing.T) { + path := setupCreditCardDB(t) + + count, err := countCreditCards(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountCreditCards_Empty(t *testing.T) { + path := createTestDB(t, "Web Data", creditCardsSchema) + + count, err := countCreditCards(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/browser/chromium/extract_download.go b/browser/chromium/extract_download.go index 484a195..3eb91dc 100644 --- a/browser/chromium/extract_download.go +++ b/browser/chromium/extract_download.go @@ -8,8 +8,11 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const defaultDownloadQuery = `SELECT target_path, tab_url, total_bytes, start_time, end_time, - mime_type FROM downloads` +const ( + defaultDownloadQuery = `SELECT target_path, tab_url, total_bytes, start_time, end_time, + mime_type FROM downloads` + countDownloadQuery = `SELECT COUNT(*) FROM downloads` +) func extractDownloads(path string) ([]types.DownloadEntry, error) { downloads, err := sqliteutil.QueryRows(path, false, defaultDownloadQuery, @@ -37,3 +40,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { }) return downloads, nil } + +func countDownloads(path string) (int, error) { + return sqliteutil.CountRows(path, false, countDownloadQuery) +} diff --git a/browser/chromium/extract_download_test.go b/browser/chromium/extract_download_test.go index 5b98434..9019b02 100644 --- a/browser/chromium/extract_download_test.go +++ b/browser/chromium/extract_download_test.go @@ -7,11 +7,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractDownloads(t *testing.T) { - path := createTestDB(t, "History", downloadsSchema, +func setupDownloadDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "History", downloadsSchema, insertDownload("/tmp/old.zip", "https://old.com/file.zip", "application/zip", 1024, 13340000000000000, 13340000100000000), insertDownload("/tmp/new.pdf", "https://new.com/doc.pdf", "application/pdf", 2048, 13360000000000000, 13360000200000000), ) +} + +func TestExtractDownloads(t *testing.T) { + path := setupDownloadDB(t) got, err := extractDownloads(path) require.NoError(t, err) @@ -29,3 +34,19 @@ func TestExtractDownloads(t *testing.T) { assert.False(t, got[0].EndTime.IsZero()) assert.True(t, got[0].StartTime.Before(got[0].EndTime)) } + +func TestCountDownloads(t *testing.T) { + path := setupDownloadDB(t) + + count, err := countDownloads(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountDownloads_Empty(t *testing.T) { + path := createTestDB(t, "History", downloadsSchema) + + count, err := countDownloads(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/browser/chromium/extract_extension.go b/browser/chromium/extract_extension.go index 7e4519f..1cd1812 100644 --- a/browser/chromium/extract_extension.go +++ b/browser/chromium/extract_extension.go @@ -85,3 +85,45 @@ func isExtensionEnabled(ext gjson.Result) bool { func extractOperaExtensions(path string) ([]types.ExtensionEntry, error) { return extractExtensionsWithKeys(path, []string{"extensions.opsettings"}) } + +func countExtensions(path string) (int, error) { + return countExtensionsWithKeys(path, defaultExtensionKeys) +} + +func countOperaExtensions(path string) (int, error) { + return countExtensionsWithKeys(path, []string{"extensions.opsettings"}) +} + +// countExtensionsWithKeys counts non-system extensions without building +// full ExtensionEntry structs. Mirrors the filtering logic in extractExtensionsWithKeys. +func countExtensionsWithKeys(path string, keys []string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + + var settings gjson.Result + for _, key := range keys { + settings = gjson.GetBytes(data, key) + if settings.Exists() { + break + } + } + if !settings.Exists() { + return 0, nil + } + + var count int + settings.ForEach(func(_, ext gjson.Result) bool { + location := ext.Get("location").Int() + if location == 5 || location == 10 { + return true + } + if !ext.Get("manifest").Exists() { + return true + } + count++ + return true + }) + return count, nil +} diff --git a/browser/chromium/extract_extension_test.go b/browser/chromium/extract_extension_test.go index 52159f5..63a2dd8 100644 --- a/browser/chromium/extract_extension_test.go +++ b/browser/chromium/extract_extension_test.go @@ -7,8 +7,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractExtensions(t *testing.T) { - path := createTestJSON(t, "Secure Preferences", `{ +func setupExtensionJSON(t *testing.T) string { + t.Helper() + return createTestJSON(t, "Secure Preferences", `{ "extensions": { "settings": { "abc123": { @@ -38,6 +39,10 @@ func TestExtractExtensions(t *testing.T) { } } }`) +} + +func TestExtractExtensions(t *testing.T) { + path := setupExtensionJSON(t) got, err := extractExtensions(path) require.NoError(t, err) @@ -56,6 +61,45 @@ func TestExtractExtensions(t *testing.T) { assert.False(t, ids["system-ext"]) } +func TestCountExtensions(t *testing.T) { + path := setupExtensionJSON(t) + + count, err := countExtensions(path) + require.NoError(t, err) + assert.Equal(t, 2, count) // system (5) and component (10) skipped +} + +func TestCountOperaExtensions(t *testing.T) { + path := createTestJSON(t, "Secure Preferences", `{ + "extensions": { + "opsettings": { + "opera-ext-1": { + "location": 1, + "manifest": {"name": "Opera Ad Blocker", "version": "2.0.0"} + }, + "system-ext": { + "location": 5, + "manifest": {"name": "System", "version": "1.0"} + } + } + } + }`) + + count, err := countOperaExtensions(path) + require.NoError(t, err) + assert.Equal(t, 1, count) +} + +func TestCountExtensions_Empty(t *testing.T) { + path := createTestJSON(t, "Secure Preferences", `{ + "extensions": {"settings": {}} + }`) + + count, err := countExtensions(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractExtensions_NoManifestSkipped(t *testing.T) { path := createTestJSON(t, "Secure Preferences", `{ "extensions": { diff --git a/browser/chromium/extract_history.go b/browser/chromium/extract_history.go index 39ce189..b00a659 100644 --- a/browser/chromium/extract_history.go +++ b/browser/chromium/extract_history.go @@ -8,7 +8,10 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const defaultHistoryQuery = `SELECT url, title, visit_count, last_visit_time FROM urls` +const ( + defaultHistoryQuery = `SELECT url, title, visit_count, last_visit_time FROM urls` + countHistoryQuery = `SELECT COUNT(*) FROM urls` +) func extractHistories(path string) ([]types.HistoryEntry, error) { histories, err := sqliteutil.QueryRows(path, false, defaultHistoryQuery, @@ -35,3 +38,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { }) return histories, nil } + +func countHistories(path string) (int, error) { + return sqliteutil.CountRows(path, false, countHistoryQuery) +} diff --git a/browser/chromium/extract_history_test.go b/browser/chromium/extract_history_test.go index 9bfd35e..7c5d44c 100644 --- a/browser/chromium/extract_history_test.go +++ b/browser/chromium/extract_history_test.go @@ -7,12 +7,17 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractHistories(t *testing.T) { - path := createTestDB(t, "History", urlsSchema, +func setupHistoryDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "History", urlsSchema, insertURL("https://github.com", "GitHub", 100, 13370000000000000), insertURL("https://go.dev", "Go Dev", 50, 13360000000000000), insertURL("https://example.com", "Example", 200, 13350000000000000), ) +} + +func TestExtractHistories(t *testing.T) { + path := setupHistoryDB(t) got, err := extractHistories(path) require.NoError(t, err) @@ -29,6 +34,22 @@ func TestExtractHistories(t *testing.T) { assert.False(t, got[0].LastVisit.IsZero()) } +func TestCountHistories(t *testing.T) { + path := setupHistoryDB(t) + + count, err := countHistories(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestCountHistories_Empty(t *testing.T) { + path := createTestDB(t, "History", urlsSchema) + + count, err := countHistories(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractHistories_FileNotFound(t *testing.T) { _, err := extractHistories("/nonexistent/History") require.Error(t, err) diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index d90c77d..bc6f79b 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -9,7 +9,10 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` +const ( + defaultLoginQuery = `SELECT origin_url, username_value, password_value, date_created FROM logins` + countLoginQuery = `SELECT COUNT(*) FROM logins` +) func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) { return extractPasswordsWithQuery(masterKey, path, defaultLoginQuery) @@ -57,3 +60,7 @@ func extractYandexPasswords(masterKey []byte, path string) ([]types.LoginEntry, const yandexLoginQuery = `SELECT action_url, username_value, password_value, date_created FROM logins` return extractPasswordsWithQuery(masterKey, path, yandexLoginQuery) } + +func countPasswords(path string) (int, error) { + return sqliteutil.CountRows(path, false, countLoginQuery) +} diff --git a/browser/chromium/extract_password_test.go b/browser/chromium/extract_password_test.go index 4ef2de0..446e935 100644 --- a/browser/chromium/extract_password_test.go +++ b/browser/chromium/extract_password_test.go @@ -7,11 +7,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractPasswords(t *testing.T) { - path := createTestDB(t, "Login Data", loginsSchema, +func setupLoginDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "Login Data", loginsSchema, insertLogin("https://old.com", "https://old.com/login", "alice", "", 13340000000000000), insertLogin("https://new.com", "https://new.com/login", "bob", "", 13360000000000000), ) +} + +func TestExtractPasswords(t *testing.T) { + path := setupLoginDB(t) got, err := extractPasswords(nil, path) require.NoError(t, err) @@ -28,6 +33,22 @@ func TestExtractPasswords(t *testing.T) { assert.Empty(t, got[0].Password) } +func TestCountPasswords(t *testing.T) { + path := setupLoginDB(t) + + count, err := countPasswords(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountPasswords_Empty(t *testing.T) { + path := createTestDB(t, "Login Data", loginsSchema) + + count, err := countPasswords(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractYandexPasswords(t *testing.T) { path := createTestDB(t, "Ya Passman Data", loginsSchema, insertLogin("https://origin.yandex.ru", "https://action.yandex.ru/submit", "user", "", 13350000000000000), diff --git a/browser/chromium/extract_storage.go b/browser/chromium/extract_storage.go index f70928a..7c9dfc7 100644 --- a/browser/chromium/extract_storage.go +++ b/browser/chromium/extract_storage.go @@ -238,6 +238,53 @@ func extractNamespaceOrigin(key string) string { return "" } +func countLocalStorage(path string) (int, error) { + if _, err := os.Stat(path); err != nil { + return 0, err + } + db, err := leveldb.OpenFile(path, nil) + if err != nil { + return 0, err + } + defer db.Close() + + var count int + iter := db.NewIterator(nil, nil) + defer iter.Release() + + for iter.Next() { + if _, ok := parseLocalStorageEntry(iter.Key(), iter.Value()); ok { + count++ + } + } + return count, iter.Error() +} + +func countSessionStorage(path string) (int, error) { + if _, err := os.Stat(path); err != nil { + return 0, err + } + db, err := leveldb.OpenFile(path, nil) + if err != nil { + return 0, err + } + defer db.Close() + + var count int + iter := db.NewIterator(nil, nil) + defer iter.Release() + + mapPrefix := []byte("map-") + for iter.Next() { + if bytes.HasPrefix(iter.Key(), mapPrefix) { + if sep := bytes.IndexByte(iter.Key()[len(mapPrefix):], '-'); sep >= 0 { + count++ + } + } + } + return count, iter.Error() +} + // decodeSessionStorageValue decodes a session storage value. // Values are raw UTF-16 LE (no format byte prefix, unlike localStorage). func decodeSessionStorageValue(value []byte) string { diff --git a/browser/chromium/extract_storage_test.go b/browser/chromium/extract_storage_test.go index 453cac4..e014001 100644 --- a/browser/chromium/extract_storage_test.go +++ b/browser/chromium/extract_storage_test.go @@ -162,14 +162,31 @@ func TestParseLocalStorageEntry(t *testing.T) { // extractLocalStorage (integration with LevelDB) // --------------------------------------------------------------------------- -func TestExtractLocalStorage(t *testing.T) { - dir := createTestLevelDB(t, map[string]string{ +func setupLocalStorageLevelDB(t *testing.T) string { + t.Helper() + return createTestLevelDB(t, map[string]string{ localStorageVersionKey: "1", localStorageMetaPrefix + "https://example.com": string([]byte{0x08, 0x96, 0x01}), localStorageMetaAccessKey + "https://example.com": string([]byte{0x10, 0x20}), string(append([]byte("_https://example.com\x00"), testEncodeLatin1("token")...)): string(testEncodeLatin1("abc123")), string(append([]byte("_https://example.com\x00"), testEncodeUTF16("テスト")...)): string(testEncodeUTF16("データ")), }) +} + +func setupSessionStorageLevelDB(t *testing.T) string { + t.Helper() + return createTestLevelDB(t, map[string]string{ + "namespace-abcd1234_5678_9abc_def0_111111111111-https://github.com/": "100", + "namespace-abcd1234_5678_9abc_def0_111111111111-https://example.com/": "101", + "map-100-__darkreader__wasEnabledForHost": string(testEncodeUTF16Raw("false")), + "map-101-token": string(testEncodeUTF16Raw("abc123")), + "next-map-id": "200", + "version": "1", + }) +} + +func TestExtractLocalStorage(t *testing.T) { + dir := setupLocalStorageLevelDB(t) got, err := extractLocalStorage(dir) require.NoError(t, err) @@ -196,17 +213,7 @@ func TestExtractLocalStorage(t *testing.T) { // --------------------------------------------------------------------------- func TestExtractSessionStorage(t *testing.T) { - dir := createTestLevelDB(t, map[string]string{ - // Namespace entry: maps guid+origin → map_id - "namespace-abcd1234_5678_9abc_def0_111111111111-https://github.com/": "100", - "namespace-abcd1234_5678_9abc_def0_111111111111-https://example.com/": "101", - // Map entries: actual data (values are raw UTF-16 LE) - "map-100-__darkreader__wasEnabledForHost": string(testEncodeUTF16Raw("false")), - "map-101-token": string(testEncodeUTF16Raw("abc123")), - // Metadata: should be skipped - "next-map-id": "200", - "version": "1", - }) + dir := setupSessionStorageLevelDB(t) got, err := extractSessionStorage(dir) require.NoError(t, err) @@ -220,6 +227,41 @@ func TestExtractSessionStorage(t *testing.T) { assert.Equal(t, "abc123", byKey["https://example.com//token"]) } +// --------------------------------------------------------------------------- +// countLocalStorage +// --------------------------------------------------------------------------- + +func TestCountLocalStorage(t *testing.T) { + dir := setupLocalStorageLevelDB(t) + + count, err := countLocalStorage(dir) + require.NoError(t, err) + assert.Equal(t, 4, count) // VERSION filtered, 2 META + 2 data entries kept +} + +// --------------------------------------------------------------------------- +// countSessionStorage +// --------------------------------------------------------------------------- + +func TestCountSessionStorage(t *testing.T) { + dir := setupSessionStorageLevelDB(t) + + count, err := countSessionStorage(dir) + require.NoError(t, err) + assert.Equal(t, 2, count) // only map- entries with key separator +} + +func TestCountSessionStorage_Empty(t *testing.T) { + dir := createTestLevelDB(t, map[string]string{ + "next-map-id": "1", + "version": "1", + }) + + count, err := countSessionStorage(dir) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/browser/chromium/testutil_test.go b/browser/chromium/testutil_test.go index 5f62e90..4df820f 100644 --- a/browser/chromium/testutil_test.go +++ b/browser/chromium/testutil_test.go @@ -196,6 +196,16 @@ func insertCreditCard(name string, month, year int, encNumberHex, nickName, addr // Test fixture builders // --------------------------------------------------------------------------- +// installFile copies a test fixture file into a profile directory. +// This bridges per-category setup functions (which return standalone paths) +// and browser-level integration tests (which need files inside a profile). +func installFile(t *testing.T, profileDir, srcPath, dstName string) { + t.Helper() + data, err := os.ReadFile(srcPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(profileDir, dstName), data, 0o644)) +} + // createTestDB creates a SQLite database with the given schema and insert statements. func createTestDB(t *testing.T, name, schema string, inserts ...string) string { t.Helper() diff --git a/browser/firefox/extract_bookmark.go b/browser/firefox/extract_bookmark.go index 731d2d6..8ed2d6a 100644 --- a/browser/firefox/extract_bookmark.go +++ b/browser/firefox/extract_bookmark.go @@ -8,8 +8,12 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const firefoxBookmarkQuery = `SELECT id, url, type, dateAdded, COALESCE(title, '') - FROM (SELECT * FROM moz_bookmarks INNER JOIN moz_places ON moz_bookmarks.fk=moz_places.id)` +const ( + firefoxBookmarkQuery = `SELECT id, url, type, dateAdded, COALESCE(title, '') + FROM (SELECT * FROM moz_bookmarks INNER JOIN moz_places ON moz_bookmarks.fk=moz_places.id)` + firefoxCountBookmarkQuery = `SELECT COUNT(*) FROM moz_bookmarks + INNER JOIN moz_places ON moz_bookmarks.fk=moz_places.id` +) func extractBookmarks(path string) ([]types.BookmarkEntry, error) { bookmarks, err := sqliteutil.QueryRows(path, true, firefoxBookmarkQuery, @@ -45,3 +49,7 @@ func bookmarkType(bt int64) string { return "folder" } } + +func countBookmarks(path string) (int, error) { + return sqliteutil.CountRows(path, true, firefoxCountBookmarkQuery) +} diff --git a/browser/firefox/extract_bookmark_test.go b/browser/firefox/extract_bookmark_test.go index e557b99..8c0a28f 100644 --- a/browser/firefox/extract_bookmark_test.go +++ b/browser/firefox/extract_bookmark_test.go @@ -7,14 +7,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractBookmarks(t *testing.T) { - // Bookmarks require JOIN: moz_bookmarks.fk = moz_places.id - path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozBookmarksSchema}, +func setupMozBookmarkDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozBookmarksSchema}, insertMozPlace(1, "https://go.dev", "Go", 0, 0), insertMozPlace(2, "https://github.com", "GitHub", 0, 0), insertMozBookmark(1, 1, 1, "Go Website", 1700000000000000), insertMozBookmark(2, 2, 1, "GitHub", 1710000000000000), ) +} + +func TestExtractBookmarks(t *testing.T) { + path := setupMozBookmarkDB(t) got, err := extractBookmarks(path) require.NoError(t, err) @@ -29,3 +33,19 @@ func TestExtractBookmarks(t *testing.T) { assert.Equal(t, "url", got[0].Folder) // type=1 → "url" assert.False(t, got[0].CreatedAt.IsZero()) } + +func TestCountBookmarks(t *testing.T) { + path := setupMozBookmarkDB(t) + + count, err := countBookmarks(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountBookmarks_Empty(t *testing.T) { + path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozBookmarksSchema}) + + count, err := countBookmarks(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/browser/firefox/extract_cookie.go b/browser/firefox/extract_cookie.go index c5d03ed..640acb3 100644 --- a/browser/firefox/extract_cookie.go +++ b/browser/firefox/extract_cookie.go @@ -8,8 +8,11 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const firefoxCookieQuery = `SELECT name, value, host, path, - creationTime, expiry, isSecure, isHttpOnly FROM moz_cookies` +const ( + firefoxCookieQuery = `SELECT name, value, host, path, + creationTime, expiry, isSecure, isHttpOnly FROM moz_cookies` + firefoxCountCookieQuery = `SELECT COUNT(*) FROM moz_cookies` +) func extractCookies(path string) ([]types.CookieEntry, error) { cookies, err := sqliteutil.QueryRows(path, true, firefoxCookieQuery, @@ -46,3 +49,7 @@ func extractCookies(path string) ([]types.CookieEntry, error) { }) return cookies, nil } + +func countCookies(path string) (int, error) { + return sqliteutil.CountRows(path, true, firefoxCountCookieQuery) +} diff --git a/browser/firefox/extract_cookie_test.go b/browser/firefox/extract_cookie_test.go index ef92ef8..5286e25 100644 --- a/browser/firefox/extract_cookie_test.go +++ b/browser/firefox/extract_cookie_test.go @@ -7,11 +7,16 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractCookies(t *testing.T) { - path := createTestDB(t, "cookies.sqlite", []string{mozCookiesSchema}, +func setupMozCookieDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "cookies.sqlite", []string{mozCookiesSchema}, insertMozCookie("session", "abc123", ".example.com", "/", 1700000000000000, 1800000000, 1, 1), insertMozCookie("token", "xyz789", ".new.com", "/api", 1710000000000000, 1810000000, 1, 0), ) +} + +func TestExtractCookies(t *testing.T) { + path := setupMozCookieDB(t) got, err := extractCookies(path) require.NoError(t, err) @@ -33,3 +38,19 @@ func TestExtractCookies(t *testing.T) { assert.Equal(t, "abc123", got[1].Value) assert.True(t, got[1].IsHTTPOnly) } + +func TestCountCookies(t *testing.T) { + path := setupMozCookieDB(t) + + count, err := countCookies(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountCookies_Empty(t *testing.T) { + path := createTestDB(t, "cookies.sqlite", []string{mozCookiesSchema}) + + count, err := countCookies(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/browser/firefox/extract_download.go b/browser/firefox/extract_download.go index 75a342d..febc7da 100644 --- a/browser/firefox/extract_download.go +++ b/browser/firefox/extract_download.go @@ -11,9 +11,15 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const firefoxDownloadQuery = `SELECT place_id, GROUP_CONCAT(content), url, dateAdded - FROM (SELECT * FROM moz_annos INNER JOIN moz_places ON moz_annos.place_id=moz_places.id) - t GROUP BY place_id` +const ( + firefoxDownloadQuery = `SELECT place_id, GROUP_CONCAT(content), url, dateAdded + FROM (SELECT * FROM moz_annos INNER JOIN moz_places ON moz_annos.place_id=moz_places.id) + t GROUP BY place_id` + firefoxCountDownloadQuery = `SELECT COUNT(*) FROM + (SELECT place_id FROM moz_annos + INNER JOIN moz_places ON moz_annos.place_id=moz_places.id + GROUP BY place_id)` +) func extractDownloads(path string) ([]types.DownloadEntry, error) { downloads, err := sqliteutil.QueryRows(path, true, firefoxDownloadQuery, @@ -52,3 +58,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { }) return downloads, nil } + +func countDownloads(path string) (int, error) { + return sqliteutil.CountRows(path, true, firefoxCountDownloadQuery) +} diff --git a/browser/firefox/extract_download_test.go b/browser/firefox/extract_download_test.go index 95113d7..085dc58 100644 --- a/browser/firefox/extract_download_test.go +++ b/browser/firefox/extract_download_test.go @@ -7,14 +7,18 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractDownloads(t *testing.T) { - // Downloads require JOIN: moz_annos.place_id = moz_places.id - path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozAnnosSchema}, +func setupMozDownloadDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozAnnosSchema}, insertMozPlace(1, "https://example.com/old.zip", "Old File", 0, 0), insertMozPlace(2, "https://example.com/new.pdf", "New File", 0, 0), insertMozAnno(1, "/tmp/old.zip", 1700000000000000), insertMozAnno(2, "/tmp/new.pdf", 1710000000000000), ) +} + +func TestExtractDownloads(t *testing.T) { + path := setupMozDownloadDB(t) got, err := extractDownloads(path) require.NoError(t, err) @@ -28,3 +32,19 @@ func TestExtractDownloads(t *testing.T) { assert.Equal(t, "/tmp/new.pdf", got[0].TargetPath) assert.False(t, got[0].StartTime.IsZero()) } + +func TestCountDownloads(t *testing.T) { + path := setupMozDownloadDB(t) + + count, err := countDownloads(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestCountDownloads_Empty(t *testing.T) { + path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema, mozAnnosSchema}) + + count, err := countDownloads(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} diff --git a/browser/firefox/extract_extension.go b/browser/firefox/extract_extension.go index e077fb4..4a14109 100644 --- a/browser/firefox/extract_extension.go +++ b/browser/firefox/extract_extension.go @@ -34,3 +34,18 @@ func extractExtensions(path string) ([]types.ExtensionEntry, error) { return extensions, nil } + +func countExtensions(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + + var count int + for _, v := range gjson.GetBytes(data, "addons").Array() { + if v.Get("location").String() == "app-profile" { + count++ + } + } + return count, nil +} diff --git a/browser/firefox/extract_extension_test.go b/browser/firefox/extract_extension_test.go index 84b2456..96bf88f 100644 --- a/browser/firefox/extract_extension_test.go +++ b/browser/firefox/extract_extension_test.go @@ -7,8 +7,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractExtensions(t *testing.T) { - path := createTestJSON(t, "extensions.json", `{ +func setupMozExtensionJSON(t *testing.T) string { + t.Helper() + return createTestJSON(t, "extensions.json", `{ "addons": [ { "id": "ublock@gorhill.org", @@ -38,6 +39,10 @@ func TestExtractExtensions(t *testing.T) { } ] }`) +} + +func TestExtractExtensions(t *testing.T) { + path := setupMozExtensionJSON(t) got, err := extractExtensions(path) require.NoError(t, err) @@ -54,6 +59,22 @@ func TestExtractExtensions(t *testing.T) { assert.False(t, ids["system@mozilla.org"]) } +func TestCountExtensions(t *testing.T) { + path := setupMozExtensionJSON(t) + + count, err := countExtensions(path) + require.NoError(t, err) + assert.Equal(t, 2, count) // system addon filtered out +} + +func TestCountExtensions_Empty(t *testing.T) { + path := createTestJSON(t, "extensions.json", `{"addons": []}`) + + count, err := countExtensions(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractExtensions_EmptyAddons(t *testing.T) { path := createTestJSON(t, "extensions.json", `{"addons": []}`) got, err := extractExtensions(path) diff --git a/browser/firefox/extract_history.go b/browser/firefox/extract_history.go index c79c057..58712a7 100644 --- a/browser/firefox/extract_history.go +++ b/browser/firefox/extract_history.go @@ -8,8 +8,11 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const firefoxHistoryQuery = `SELECT url, COALESCE(last_visit_date, 0), - COALESCE(title, ''), visit_count FROM moz_places` +const ( + firefoxHistoryQuery = `SELECT url, COALESCE(last_visit_date, 0), + COALESCE(title, ''), visit_count FROM moz_places` + firefoxCountHistoryQuery = `SELECT COUNT(*) FROM moz_places` +) func extractHistories(path string) ([]types.HistoryEntry, error) { histories, err := sqliteutil.QueryRows(path, true, firefoxHistoryQuery, @@ -36,3 +39,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { }) return histories, nil } + +func countHistories(path string) (int, error) { + return sqliteutil.CountRows(path, true, firefoxCountHistoryQuery) +} diff --git a/browser/firefox/extract_history_test.go b/browser/firefox/extract_history_test.go index d3859a0..bcbc792 100644 --- a/browser/firefox/extract_history_test.go +++ b/browser/firefox/extract_history_test.go @@ -7,12 +7,17 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractHistories(t *testing.T) { - path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema}, +func setupMozHistoryDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "places.sqlite", []string{mozPlacesSchema}, insertMozPlace(1, "https://github.com", "GitHub", 100, 1700000000000000), insertMozPlace(2, "https://go.dev", "Go", 50, 1710000000000000), insertMozPlace(3, "https://example.com", "Example", 200, 1690000000000000), ) +} + +func TestExtractHistories(t *testing.T) { + path := setupMozHistoryDB(t) got, err := extractHistories(path) require.NoError(t, err) @@ -29,6 +34,22 @@ func TestExtractHistories(t *testing.T) { assert.False(t, got[0].LastVisit.IsZero()) } +func TestCountHistories(t *testing.T) { + path := setupMozHistoryDB(t) + + count, err := countHistories(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestCountHistories_Empty(t *testing.T) { + path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema}) + + count, err := countHistories(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractHistories_NullFields(t *testing.T) { path := createTestDB(t, "places.sqlite", []string{mozPlacesSchema}, // last_visit_date=NULL, title=NULL — COALESCE should handle diff --git a/browser/firefox/extract_password.go b/browser/firefox/extract_password.go index cab09a7..ab422a1 100644 --- a/browser/firefox/extract_password.go +++ b/browser/firefox/extract_password.go @@ -13,6 +13,14 @@ import ( "github.com/moond4rk/hackbrowserdata/types" ) +func countPasswords(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + return len(gjson.GetBytes(data, "logins").Array()), nil +} + // decryptPBE combines base64 decode + ASN1 PBE parse + decrypt into one call. func decryptPBE(encoded string, masterKey []byte) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(encoded) diff --git a/browser/firefox/extract_password_test.go b/browser/firefox/extract_password_test.go index cd13934..e220014 100644 --- a/browser/firefox/extract_password_test.go +++ b/browser/firefox/extract_password_test.go @@ -53,6 +53,29 @@ func TestExtractPasswords(t *testing.T) { assert.False(t, got[0].CreatedAt.IsZero()) } +func TestCountPasswords(t *testing.T) { + json := `{ + "logins": [ + {"hostname": "https://a.com", "encryptedUsername": "", "encryptedPassword": "", "timeCreated": 1000}, + {"hostname": "https://b.com", "encryptedUsername": "", "encryptedPassword": "", "timeCreated": 2000}, + {"hostname": "https://c.com", "encryptedUsername": "", "encryptedPassword": "", "timeCreated": 3000} + ] + }` + path := createTestJSON(t, "logins.json", json) + + count, err := countPasswords(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestCountPasswords_Empty(t *testing.T) { + path := createTestJSON(t, "logins.json", `{"logins": []}`) + + count, err := countPasswords(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestExtractPasswords_FormSubmitURLFallback(t *testing.T) { encB64 := loginPBEBase64(t) diff --git a/browser/firefox/extract_storage.go b/browser/firefox/extract_storage.go index 90f9685..05972fa 100644 --- a/browser/firefox/extract_storage.go +++ b/browser/firefox/extract_storage.go @@ -9,7 +9,10 @@ import ( "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) -const firefoxLocalStorageQuery = `SELECT originKey, key, value FROM webappsstore2` +const ( + firefoxLocalStorageQuery = `SELECT originKey, key, value FROM webappsstore2` + firefoxCountLocalStorageQuery = `SELECT COUNT(*) FROM webappsstore2` +) func extractLocalStorage(path string) ([]types.StorageEntry, error) { return sqliteutil.QueryRows(path, true, firefoxLocalStorageQuery, @@ -26,6 +29,10 @@ func extractLocalStorage(path string) ([]types.StorageEntry, error) { }) } +func countLocalStorage(path string) (int, error) { + return sqliteutil.CountRows(path, true, firefoxCountLocalStorageQuery) +} + func reverseString(s string) string { b := []byte(s) for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { diff --git a/browser/firefox/extract_storage_test.go b/browser/firefox/extract_storage_test.go index 7dabef7..da1717d 100644 --- a/browser/firefox/extract_storage_test.go +++ b/browser/firefox/extract_storage_test.go @@ -7,12 +7,17 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractLocalStorage(t *testing.T) { - path := createTestDB(t, "webappsstore.sqlite", []string{webappsstore2Schema}, +func setupWebappsDB(t *testing.T) string { + t.Helper() + return createTestDB(t, "webappsstore.sqlite", []string{webappsstore2Schema}, insertWebappsstore("moc.buhtig.:https:443", "theme", "dark"), insertWebappsstore("moc.buhtig.:https:443", "lang", "en"), insertWebappsstore("moc.elpmaxe.:http:8080", "token", "abc123"), ) +} + +func TestExtractLocalStorage(t *testing.T) { + path := setupWebappsDB(t) got, err := extractLocalStorage(path) require.NoError(t, err) @@ -28,6 +33,22 @@ func TestExtractLocalStorage(t *testing.T) { assert.Equal(t, "abc123", byKey["http://example.com:8080/token"]) } +func TestCountLocalStorage(t *testing.T) { + path := setupWebappsDB(t) + + count, err := countLocalStorage(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestCountLocalStorage_Empty(t *testing.T) { + path := createTestDB(t, "webappsstore.sqlite", []string{webappsstore2Schema}) + + count, err := countLocalStorage(path) + require.NoError(t, err) + assert.Equal(t, 0, count) +} + func TestParseOriginKey(t *testing.T) { tests := []struct { name string diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index cae0726..450422f 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -83,6 +83,57 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro 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.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) diff --git a/browser/firefox/firefox_test.go b/browser/firefox/firefox_test.go index 6b0e6d5..17cd6d1 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -176,6 +176,64 @@ func TestResolveSourcePaths_Partial(t *testing.T) { assert.NotContains(t, resolved, types.Extension) } +// --------------------------------------------------------------------------- +// CountEntries +// --------------------------------------------------------------------------- + +func TestCountEntries(t *testing.T) { + dir := t.TempDir() + profileDir := filepath.Join(dir, "test-profile") + mkDir(profileDir) + installFile(t, profileDir, setupMozHistoryDB(t), "places.sqlite") + + browsers, err := NewBrowsers(types.BrowserConfig{ + Name: "Firefox", Kind: types.Firefox, UserDataDir: dir, + }) + require.NoError(t, err) + require.Len(t, browsers, 1) + + // CountEntries works without master key. + counts, err := browsers[0].CountEntries([]types.Category{types.History}) + require.NoError(t, err) + assert.Equal(t, 3, 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) { diff --git a/browser/firefox/testutil_test.go b/browser/firefox/testutil_test.go index fe6dcee..e3a5b69 100644 --- a/browser/firefox/testutil_test.go +++ b/browser/firefox/testutil_test.go @@ -141,6 +141,14 @@ func insertWebappsstore(originKey, key, value string) string { // Test fixture builders // --------------------------------------------------------------------------- +// installFile copies a test fixture file into a profile directory. +func installFile(t *testing.T, profileDir, srcPath, dstName string) { + t.Helper() + data, err := os.ReadFile(srcPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(profileDir, dstName), data, 0o644)) +} + func createTestDB(t *testing.T, name string, schemas []string, inserts ...string) string { t.Helper() path := filepath.Join(t.TempDir(), name) diff --git a/cmd/hack-browser-data/list.go b/cmd/hack-browser-data/list.go index 9766591..ef9cbe1 100644 --- a/cmd/hack-browser-data/list.go +++ b/cmd/hack-browser-data/list.go @@ -58,40 +58,12 @@ func printDetail(out io.Writer, browsers []browser.Browser) error { fmt.Fprintln(w) for _, b := range browsers { - data, _ := b.Extract(types.AllCategories) + counts, _ := b.CountEntries(types.AllCategories) fmt.Fprintf(w, "%s\t%s", b.BrowserName(), b.ProfileName()) for _, c := range types.AllCategories { - fmt.Fprintf(w, "\t%d", countEntries(data, c)) + fmt.Fprintf(w, "\t%d", counts[c]) } fmt.Fprintln(w) } return w.Flush() } - -func countEntries(data *types.BrowserData, c types.Category) int { - if data == nil { - return 0 - } - switch c { - case types.Password: - return len(data.Passwords) - case types.Cookie: - return len(data.Cookies) - case types.Bookmark: - return len(data.Bookmarks) - case types.History: - return len(data.Histories) - case types.Download: - return len(data.Downloads) - case types.CreditCard: - return len(data.CreditCards) - case types.Extension: - return len(data.Extensions) - case types.LocalStorage: - return len(data.LocalStorage) - case types.SessionStorage: - return len(data.SessionStorage) - default: - return 0 - } -} diff --git a/utils/sqliteutil/query.go b/utils/sqliteutil/query.go index 4b58008..3ff6c54 100644 --- a/utils/sqliteutil/query.go +++ b/utils/sqliteutil/query.go @@ -1,6 +1,36 @@ package sqliteutil -import "database/sql" +import ( + "database/sql" + "fmt" + "os" +) + +// CountRows runs a scalar count query (e.g. SELECT COUNT(*) FROM ...) and +// returns the integer result. Unlike QuerySQLite (which swallows per-row scan +// errors), CountRows uses QueryRow for fail-fast behavior on scan failures. +func CountRows(dbPath string, journalOff bool, query string) (int, error) { + if _, err := os.Stat(dbPath); err != nil { + return 0, fmt.Errorf("database file: %w", err) + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return 0, err + } + defer db.Close() + + if journalOff { + if _, err := db.Exec("PRAGMA journal_mode=off"); err != nil { + return 0, err + } + } + + var count int + if err := db.QueryRow(query).Scan(&count); err != nil { + return 0, fmt.Errorf("count rows: %w", err) + } + return count, nil +} // QueryRows is a generic helper (Go 1.18+) that wraps QuerySQLite and collects // results into a typed slice. Each extract method only needs to provide the diff --git a/utils/sqliteutil/sqlite_test.go b/utils/sqliteutil/sqlite_test.go index 55a2b8e..0182574 100644 --- a/utils/sqliteutil/sqlite_test.go +++ b/utils/sqliteutil/sqlite_test.go @@ -86,6 +86,79 @@ func TestQuerySQLite_BadQuery(t *testing.T) { require.Error(t, err) } +func TestCountRows(t *testing.T) { + tests := []struct { + name string + schema string + inserts string + journalOff bool + query string + wantCount int + wantErr bool + }{ + { + name: "count rows", + schema: "CREATE TABLE items (id INTEGER, name TEXT)", + inserts: "INSERT INTO items VALUES (1, 'alpha'), (2, 'beta'), (3, 'gamma')", + query: "SELECT COUNT(*) FROM items", + wantCount: 3, + }, + { + name: "empty table", + schema: "CREATE TABLE t (v TEXT)", + query: "SELECT COUNT(*) FROM t", + wantCount: 0, + }, + { + name: "journal off", + schema: "CREATE TABLE t (v TEXT)", + inserts: "INSERT INTO t VALUES ('a'), ('b')", + journalOff: true, + query: "SELECT COUNT(*) FROM t", + wantCount: 2, + }, + { + name: "file not found", + query: "SELECT COUNT(*) FROM t", + wantErr: true, + }, + { + name: "bad query", + schema: "CREATE TABLE t (v TEXT)", + query: "SELECT COUNT(*) FROM nonexistent", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dbPath string + if tt.schema != "" { + dbPath = filepath.Join(t.TempDir(), "test.db") + db, err := sql.Open("sqlite", dbPath) + require.NoError(t, err) + _, err = db.Exec(tt.schema) + require.NoError(t, err) + if tt.inserts != "" { + _, err = db.Exec(tt.inserts) + require.NoError(t, err) + } + require.NoError(t, db.Close()) + } else { + dbPath = "/nonexistent/path.db" + } + + count, err := CountRows(dbPath, tt.journalOff, tt.query) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCount, count) + }) + } +} + func TestQueryRows(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db")