diff --git a/browser/archive.go b/browser/archive.go index 28e3d346..fa9cf484 100644 --- a/browser/archive.go +++ b/browser/archive.go @@ -7,7 +7,7 @@ import ( "github.com/moond4rk/hackbrowserdata/browser/chromium" "github.com/moond4rk/hackbrowserdata/filemanager" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/fileutil" ) diff --git a/browser/browser.go b/browser/browser.go index b6be8d70..a3a7f20c 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -9,7 +9,7 @@ import ( "github.com/moond4rk/hackbrowserdata/browser/chromium" "github.com/moond4rk/hackbrowserdata/browser/firefox" "github.com/moond4rk/hackbrowserdata/browser/safari" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/browser_darwin.go b/browser/browser_darwin.go index 1f258bab..05d0c2e4 100644 --- a/browser/browser_darwin.go +++ b/browser/browser_darwin.go @@ -9,7 +9,7 @@ import ( "github.com/moond4rk/keychainbreaker" "golang.org/x/term" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index 4bdb2314..1be84565 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -4,10 +4,9 @@ import ( "os" "path/filepath" "sync" - "time" "github.com/moond4rk/hackbrowserdata/filemanager" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/fileutil" @@ -256,20 +255,3 @@ func isSkippedDir(name string) bool { } return false } - -// Offset from the Chromium epoch (1601-01-01 UTC) to the Unix epoch, -// matching base::Time::kTimeTToMicrosecondsOffset in Chromium. -const chromiumEpochOffsetMicros int64 = 11644473600000000 - -// timeEpoch converts a Chromium base::Time (μs since 1601 UTC) to UTC. -// Returns zero for non-positive input or out-of-JSON-range values. -func timeEpoch(epoch int64) time.Time { - if epoch <= 0 { - return time.Time{} - } - t := time.UnixMicro(epoch - chromiumEpochOffsetMicros).UTC() - if t.Year() < 1 || t.Year() > 9999 { - return time.Time{} - } - return t -} diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 85c472e6..037ca590 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -682,47 +681,3 @@ func TestSetRetrievers_SatisfiesInterface(t *testing.T) { SetRetrievers(masterkey.Retrievers) } = (*Browser)(nil) } - -// Anchor: 2024-01-15T10:30:00Z as Chromium microseconds since 1601 UTC. -const anchorUnixSeconds = int64(1705314600) - -var anchorChromiumMicros = (anchorUnixSeconds + 11644473600) * 1_000_000 - -func TestTimeEpoch_AnchorDate(t *testing.T) { - got := timeEpoch(anchorChromiumMicros) - want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - assert.Equal(t, want, got) - assert.Equal(t, anchorUnixSeconds, got.Unix()) -} - -func TestTimeEpoch_ZeroReturnsZeroTime(t *testing.T) { - assert.True(t, timeEpoch(0).IsZero()) -} - -func TestTimeEpoch_NegativeReturnsZeroTime(t *testing.T) { - assert.True(t, timeEpoch(-1).IsZero()) -} - -func TestTimeEpoch_AlwaysUTC(t *testing.T) { - // assert.Same checks pointer equality: time.UTC and time.Local are - // distinct *Location globals, so this catches any regression that - // drops .UTC() even when the runner's TZ happens to be UTC. - got := timeEpoch(anchorChromiumMicros) - assert.Same(t, time.UTC, got.Location()) -} - -func TestTimeEpoch_MicrosecondPrecisionPreserved(t *testing.T) { - got := timeEpoch(anchorChromiumMicros + 123456) - assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) -} - -func TestTimeEpoch_UnixEpochBoundary(t *testing.T) { - got := timeEpoch(chromiumEpochOffsetMicros) - assert.Equal(t, time.Unix(0, 0).UTC(), got) -} - -func TestTimeEpoch_OutOfJSONRangeReturnsZero(t *testing.T) { - jsonBytes, err := timeEpoch(1 << 62).MarshalJSON() - require.NoError(t, err) - assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes)) -} diff --git a/browser/chromium/extract_bookmark.go b/browser/chromium/extract_bookmark.go index 7abd8a07..65a8bd28 100644 --- a/browser/chromium/extract_bookmark.go +++ b/browser/chromium/extract_bookmark.go @@ -6,6 +6,7 @@ import ( "github.com/tidwall/gjson" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" ) @@ -38,7 +39,7 @@ func walkBookmarks(node gjson.Result, folder string, out *[]types.BookmarkEntry) Type: nodeType, URL: node.Get("url").String(), Folder: folder, - CreatedAt: timeEpoch(node.Get("date_added").Int()), + CreatedAt: timeconv.Chromium(node.Get("date_added").Int()), }) } diff --git a/browser/chromium/extract_cookie.go b/browser/chromium/extract_cookie.go index c481ae17..6c158973 100644 --- a/browser/chromium/extract_cookie.go +++ b/browser/chromium/extract_cookie.go @@ -6,9 +6,10 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -19,7 +20,7 @@ const ( ) func extractCookies(masterKeys masterkey.MasterKeys, path string) ([]types.CookieEntry, error) { - cookies, err := sqliteutil.QueryRows(path, false, defaultCookieQuery, + cookies, err := sqlitex.QueryRows(path, false, defaultCookieQuery, func(rows *sql.Rows) (types.CookieEntry, error) { var ( name, host, cookiePath string @@ -57,8 +58,8 @@ func extractCookies(masterKeys masterkey.MasterKeys, path string) ([]types.Cooki IsHTTPOnly: isHTTPOnly != 0, HasExpire: hasExpire != 0, IsPersistent: isPersistent != 0, - ExpireAt: timeEpoch(expireAt), - CreatedAt: timeEpoch(createdAt), + ExpireAt: timeconv.Chromium(expireAt), + CreatedAt: timeconv.Chromium(createdAt), SameSite: sameSiteStr, }, nil }) @@ -73,7 +74,7 @@ func extractCookies(masterKeys masterkey.MasterKeys, path string) ([]types.Cooki } func countCookies(path string) (int, error) { - return sqliteutil.CountRows(path, false, countCookieQuery) + return sqlitex.CountRows(path, false, countCookieQuery) } // stripCookieHash removes the SHA256(host_key) prefix from a decrypted cookie value. Chrome 130+ diff --git a/browser/chromium/extract_creditcard.go b/browser/chromium/extract_creditcard.go index af9ba2a3..d9c30698 100644 --- a/browser/chromium/extract_creditcard.go +++ b/browser/chromium/extract_creditcard.go @@ -6,10 +6,10 @@ import ( "errors" "github.com/moond4rk/hackbrowserdata/crypto" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -37,7 +37,7 @@ type yandexPrivateData struct { } func extractCreditCards(masterKeys masterkey.MasterKeys, path string) ([]types.CreditCardEntry, error) { - cards, err := sqliteutil.QueryRows(path, false, defaultCreditCardQuery, + cards, err := sqlitex.QueryRows(path, false, defaultCreditCardQuery, func(rows *sql.Rows) (types.CreditCardEntry, error) { var guid, name, month, year, nickname, address string var encNumber []byte @@ -72,7 +72,7 @@ func extractYandexCreditCards(masterKeys masterkey.MasterKeys, path string) ([]t return nil, err } - return sqliteutil.QueryRows(path, false, yandexCreditCardQuery, + return sqlitex.QueryRows(path, false, yandexCreditCardQuery, func(rows *sql.Rows) (types.CreditCardEntry, error) { var guid, publicData string var privateData []byte @@ -113,9 +113,9 @@ func extractYandexCreditCards(masterKeys masterkey.MasterKeys, path string) ([]t } func countCreditCards(path string) (int, error) { - return sqliteutil.CountRows(path, false, countCreditCardQuery) + return sqlitex.CountRows(path, false, countCreditCardQuery) } func countYandexCreditCards(path string) (int, error) { - return sqliteutil.CountRows(path, false, yandexCreditCardCountQuery) + return sqlitex.CountRows(path, false, yandexCreditCardCountQuery) } diff --git a/browser/chromium/extract_download.go b/browser/chromium/extract_download.go index 3eb91dcc..62e5dd31 100644 --- a/browser/chromium/extract_download.go +++ b/browser/chromium/extract_download.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -15,7 +16,7 @@ const ( ) func extractDownloads(path string) ([]types.DownloadEntry, error) { - downloads, err := sqliteutil.QueryRows(path, false, defaultDownloadQuery, + downloads, err := sqlitex.QueryRows(path, false, defaultDownloadQuery, func(rows *sql.Rows) (types.DownloadEntry, error) { var targetPath, url, mimeType string var totalBytes, startTime, endTime int64 @@ -27,8 +28,8 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { TargetPath: targetPath, MimeType: mimeType, TotalBytes: totalBytes, - StartTime: timeEpoch(startTime), - EndTime: timeEpoch(endTime), + StartTime: timeconv.Chromium(startTime), + EndTime: timeconv.Chromium(endTime), }, nil }) if err != nil { @@ -42,5 +43,5 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { } func countDownloads(path string) (int, error) { - return sqliteutil.CountRows(path, false, countDownloadQuery) + return sqlitex.CountRows(path, false, countDownloadQuery) } diff --git a/browser/chromium/extract_history.go b/browser/chromium/extract_history.go index b00a659d..58471117 100644 --- a/browser/chromium/extract_history.go +++ b/browser/chromium/extract_history.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -14,7 +15,7 @@ const ( ) func extractHistories(path string) ([]types.HistoryEntry, error) { - histories, err := sqliteutil.QueryRows(path, false, defaultHistoryQuery, + histories, err := sqlitex.QueryRows(path, false, defaultHistoryQuery, func(rows *sql.Rows) (types.HistoryEntry, error) { var url, title string var visitCount int @@ -26,7 +27,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { URL: url, Title: title, VisitCount: visitCount, - LastVisit: timeEpoch(lastVisit), + LastVisit: timeconv.Chromium(lastVisit), }, nil }) if err != nil { @@ -40,5 +41,5 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { } func countHistories(path string) (int, error) { - return sqliteutil.CountRows(path, false, countHistoryQuery) + return sqlitex.CountRows(path, false, countHistoryQuery) } diff --git a/browser/chromium/extract_password.go b/browser/chromium/extract_password.go index ae87fac9..cfff5e59 100644 --- a/browser/chromium/extract_password.go +++ b/browser/chromium/extract_password.go @@ -6,10 +6,11 @@ import ( "sort" "github.com/moond4rk/hackbrowserdata/crypto" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -40,7 +41,7 @@ func extractPasswords(masterKeys masterkey.MasterKeys, path, store string) ([]ty } func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, store, query string) ([]types.LoginEntry, error) { - logins, err := sqliteutil.QueryRows(path, false, query, + logins, err := sqlitex.QueryRows(path, false, query, func(rows *sql.Rows) (types.LoginEntry, error) { var url, username string var pwd []byte @@ -53,7 +54,7 @@ func extractPasswordsWithQuery(masterKeys masterkey.MasterKeys, path, store, que URL: url, Username: username, Password: string(password), - CreatedAt: timeEpoch(created), + CreatedAt: timeconv.Chromium(created), Store: store, }, nil }) @@ -79,7 +80,7 @@ func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]typ return nil, err } - logins, err := sqliteutil.QueryRows(path, false, yandexLoginQuery, + logins, err := sqlitex.QueryRows(path, false, yandexLoginQuery, func(rows *sql.Rows) (types.LoginEntry, error) { var originURL, usernameElem, usernameVal, passwordElem, signonRealm string var passwordValue []byte @@ -90,7 +91,7 @@ func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]typ entry := types.LoginEntry{ URL: originURL, Username: usernameVal, - CreatedAt: timeEpoch(created), + CreatedAt: timeconv.Chromium(created), Store: types.PasswordStoreLocal, } aad := yandexLoginAAD(originURL, usernameElem, usernameVal, passwordElem, signonRealm, nil) @@ -113,5 +114,5 @@ func extractYandexPasswords(masterKeys masterkey.MasterKeys, path string) ([]typ } func countPasswords(path string) (int, error) { - return sqliteutil.CountRows(path, false, countLoginQuery) + return sqlitex.CountRows(path, false, countLoginQuery) } diff --git a/browser/chromium/profile.go b/browser/chromium/profile.go index be7c737f..a06e57eb 100644 --- a/browser/chromium/profile.go +++ b/browser/chromium/profile.go @@ -5,7 +5,7 @@ import ( "path/filepath" "github.com/moond4rk/hackbrowserdata/filemanager" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/firefox/extract_bookmark.go b/browser/firefox/extract_bookmark.go index b00215b8..46d9f54d 100644 --- a/browser/firefox/extract_bookmark.go +++ b/browser/firefox/extract_bookmark.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -16,7 +17,7 @@ const ( ) func extractBookmarks(path string) ([]types.BookmarkEntry, error) { - bookmarks, err := sqliteutil.QueryRows(path, true, firefoxBookmarkQuery, + bookmarks, err := sqlitex.QueryRows(path, true, firefoxBookmarkQuery, func(rows *sql.Rows) (types.BookmarkEntry, error) { var id, dateAdded int64 var url, title string @@ -28,7 +29,7 @@ func extractBookmarks(path string) ([]types.BookmarkEntry, error) { Name: title, URL: url, Folder: bookmarkType(bt), - CreatedAt: firefoxMicros(dateAdded), + CreatedAt: timeconv.UnixMicro(dateAdded), }, nil }) if err != nil { @@ -51,5 +52,5 @@ func bookmarkType(bt int64) string { } func countBookmarks(path string) (int, error) { - return sqliteutil.CountRows(path, true, firefoxCountBookmarkQuery) + return sqlitex.CountRows(path, true, firefoxCountBookmarkQuery) } diff --git a/browser/firefox/extract_cookie.go b/browser/firefox/extract_cookie.go index 30f00839..2753c0ab 100644 --- a/browser/firefox/extract_cookie.go +++ b/browser/firefox/extract_cookie.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -15,7 +16,7 @@ const ( ) func extractCookies(path string) ([]types.CookieEntry, error) { - cookies, err := sqliteutil.QueryRows(path, true, firefoxCookieQuery, + cookies, err := sqlitex.QueryRows(path, true, firefoxCookieQuery, func(rows *sql.Rows) (types.CookieEntry, error) { var ( name, value, host, cookiePath string @@ -48,8 +49,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) { IsHTTPOnly: isHTTPOnly != 0, HasExpire: hasExpire, IsPersistent: hasExpire, - ExpireAt: firefoxSeconds(expiry), - CreatedAt: firefoxMicros(createdAt), + ExpireAt: timeconv.Unix(expiry), + CreatedAt: timeconv.UnixMicro(createdAt), SameSite: sameSiteStr, }, nil }) @@ -64,5 +65,5 @@ func extractCookies(path string) ([]types.CookieEntry, error) { } func countCookies(path string) (int, error) { - return sqliteutil.CountRows(path, true, firefoxCountCookieQuery) + return sqlitex.CountRows(path, true, firefoxCountCookieQuery) } diff --git a/browser/firefox/extract_download.go b/browser/firefox/extract_download.go index c5b63dd4..0fbef5ff 100644 --- a/browser/firefox/extract_download.go +++ b/browser/firefox/extract_download.go @@ -7,8 +7,9 @@ import ( "github.com/tidwall/gjson" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -22,7 +23,7 @@ const ( ) func extractDownloads(path string) ([]types.DownloadEntry, error) { - downloads, err := sqliteutil.QueryRows(path, true, firefoxDownloadQuery, + downloads, err := sqlitex.QueryRows(path, true, firefoxDownloadQuery, func(rows *sql.Rows) (types.DownloadEntry, error) { var placeID, dateAdded int64 var content, url string @@ -32,7 +33,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { entry := types.DownloadEntry{ URL: url, - StartTime: firefoxMicros(dateAdded), + StartTime: timeconv.UnixMicro(dateAdded), } // Firefox stores download metadata as: "target_path,{json}" @@ -42,7 +43,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { entry.TargetPath = contentList[0] json := "{" + contentList[1] entry.TotalBytes = gjson.Get(json, "fileSize").Int() - entry.EndTime = firefoxMillis(gjson.Get(json, "endTime").Int()) + entry.EndTime = timeconv.UnixMilli(gjson.Get(json, "endTime").Int()) } else { entry.TargetPath = content } @@ -60,5 +61,5 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { } func countDownloads(path string) (int, error) { - return sqliteutil.CountRows(path, true, firefoxCountDownloadQuery) + return sqlitex.CountRows(path, true, firefoxCountDownloadQuery) } diff --git a/browser/firefox/extract_history.go b/browser/firefox/extract_history.go index 13502ddc..014d6dc3 100644 --- a/browser/firefox/extract_history.go +++ b/browser/firefox/extract_history.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -15,7 +16,7 @@ const ( ) func extractHistories(path string) ([]types.HistoryEntry, error) { - histories, err := sqliteutil.QueryRows(path, true, firefoxHistoryQuery, + histories, err := sqlitex.QueryRows(path, true, firefoxHistoryQuery, func(rows *sql.Rows) (types.HistoryEntry, error) { var url, title string var visitCount int @@ -27,7 +28,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { URL: url, Title: title, VisitCount: visitCount, - LastVisit: firefoxMicros(lastVisit), + LastVisit: timeconv.UnixMicro(lastVisit), }, nil }) if err != nil { @@ -41,5 +42,5 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { } func countHistories(path string) (int, error) { - return sqliteutil.CountRows(path, true, firefoxCountHistoryQuery) + return sqlitex.CountRows(path, true, firefoxCountHistoryQuery) } diff --git a/browser/firefox/extract_password.go b/browser/firefox/extract_password.go index 58ef79b2..1564e304 100644 --- a/browser/firefox/extract_password.go +++ b/browser/firefox/extract_password.go @@ -9,7 +9,8 @@ import ( "github.com/tidwall/gjson" "github.com/moond4rk/hackbrowserdata/crypto" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" ) @@ -68,7 +69,7 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) URL: url, Username: string(user), Password: string(pwd), - CreatedAt: firefoxMillis(v.Get("timeCreated").Int()), + CreatedAt: timeconv.UnixMilli(v.Get("timeCreated").Int()), Store: types.PasswordStoreLocal, }) } diff --git a/browser/firefox/extract_storage.go b/browser/firefox/extract_storage.go index 05972fa7..f20ee9f2 100644 --- a/browser/firefox/extract_storage.go +++ b/browser/firefox/extract_storage.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -15,7 +15,7 @@ const ( ) func extractLocalStorage(path string) ([]types.StorageEntry, error) { - return sqliteutil.QueryRows(path, true, firefoxLocalStorageQuery, + return sqlitex.QueryRows(path, true, firefoxLocalStorageQuery, func(rows *sql.Rows) (types.StorageEntry, error) { var originKey, key, value string if err := rows.Scan(&originKey, &key, &value); err != nil { @@ -30,7 +30,7 @@ func extractLocalStorage(path string) ([]types.StorageEntry, error) { } func countLocalStorage(path string) (int, error) { - return sqliteutil.CountRows(path, true, firefoxCountLocalStorageQuery) + return sqlitex.CountRows(path, true, firefoxCountLocalStorageQuery) } func reverseString(s string) string { diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index 3f7ab2cf..767bb416 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "time" "github.com/moond4rk/hackbrowserdata/types" ) @@ -166,39 +165,3 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir stri } return resolved } - -// Firefox uses three timestamp units. Helpers emit UTC and return the zero -// time.Time for non-positive or out-of-JSON-range input. -// -// - firefoxMicros: PRTime (μs since Unix epoch) — moz_* tables. -// - firefoxMillis: Date.now() (ms) — logins.json, download endTime. -// - firefoxSeconds: seconds — moz_cookies.expiry only. -func firefoxMicros(us int64) time.Time { - if us <= 0 { - return time.Time{} - } - return clampJSON(time.UnixMicro(us).UTC()) -} - -func firefoxMillis(ms int64) time.Time { - if ms <= 0 { - return time.Time{} - } - return clampJSON(time.UnixMilli(ms).UTC()) -} - -func firefoxSeconds(s int64) time.Time { - if s <= 0 { - return time.Time{} - } - return clampJSON(time.Unix(s, 0).UTC()) -} - -// clampJSON maps years outside time.Time.MarshalJSON's [1, 9999] window -// to the zero time, so JSON export can't crash on sentinel inputs. -func clampJSON(t time.Time) time.Time { - if t.Year() < 1 || t.Year() > 9999 { - return time.Time{} - } - return t -} diff --git a/browser/firefox/firefox_test.go b/browser/firefox/firefox_test.go index be0d7c22..d1f76d64 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -200,79 +199,3 @@ func TestCountEntries(t *testing.T) { require.Len(t, results, 1) assert.Equal(t, 3, results[0].Counts[types.History]) } - -// Anchor: 2024-01-15T10:30:00Z. -const anchorUnixSeconds = int64(1705314600) - -func TestFirefoxMicros_AnchorDate(t *testing.T) { - got := firefoxMicros(anchorUnixSeconds * 1_000_000) - want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - assert.Equal(t, want, got) -} - -func TestFirefoxMicros_PrecisionPreserved(t *testing.T) { - got := firefoxMicros(anchorUnixSeconds*1_000_000 + 123456) - assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) -} - -func TestFirefoxMillis_AnchorDate(t *testing.T) { - got := firefoxMillis(anchorUnixSeconds * 1_000) - want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - assert.Equal(t, want, got) -} - -func TestFirefoxMillis_PrecisionPreserved(t *testing.T) { - got := firefoxMillis(anchorUnixSeconds*1_000 + 789) - assert.Equal(t, 789*int64(time.Millisecond), int64(got.Nanosecond())) -} - -func TestFirefoxSeconds_AnchorDate(t *testing.T) { - got := firefoxSeconds(anchorUnixSeconds) - want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - assert.Equal(t, want, got) -} - -func TestFirefoxHelpers_ZeroReturnsZeroTime(t *testing.T) { - assert.True(t, firefoxMicros(0).IsZero(), "micros") - assert.True(t, firefoxMillis(0).IsZero(), "millis") - assert.True(t, firefoxSeconds(0).IsZero(), "seconds") -} - -func TestFirefoxHelpers_NegativeReturnsZeroTime(t *testing.T) { - assert.True(t, firefoxMicros(-1).IsZero(), "micros") - assert.True(t, firefoxMillis(-1).IsZero(), "millis") - assert.True(t, firefoxSeconds(-1).IsZero(), "seconds") -} - -func TestFirefoxHelpers_AlwaysUTC(t *testing.T) { - // assert.Same: pointer equality reliably catches any helper that - // leaks time.Local, independent of the runner's configured TZ. - assert.Same(t, time.UTC, firefoxMicros(anchorUnixSeconds*1_000_000).Location()) - assert.Same(t, time.UTC, firefoxMillis(anchorUnixSeconds*1_000).Location()) - assert.Same(t, time.UTC, firefoxSeconds(anchorUnixSeconds).Location()) -} - -func TestFirefoxHelpers_SameMomentAcrossUnits(t *testing.T) { - us := firefoxMicros(anchorUnixSeconds * 1_000_000) - ms := firefoxMillis(anchorUnixSeconds * 1_000) - s := firefoxSeconds(anchorUnixSeconds) - assert.True(t, us.Equal(ms)) - assert.True(t, ms.Equal(s)) -} - -func TestFirefoxHelpers_OutOfJSONRangeReturnsZero(t *testing.T) { - for _, tc := range []struct { - name string - got time.Time - }{ - {"seconds", firefoxSeconds(1 << 50)}, - {"millis", firefoxMillis(1 << 60)}, - {"micros", firefoxMicros(1 << 62)}, - } { - t.Run(tc.name, func(t *testing.T) { - b, err := tc.got.MarshalJSON() - require.NoError(t, err) - assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(b)) - }) - } -} diff --git a/browser/firefox/masterkey.go b/browser/firefox/masterkey.go index 5bae2312..30aabb4e 100644 --- a/browser/firefox/masterkey.go +++ b/browser/firefox/masterkey.go @@ -12,7 +12,7 @@ import ( _ "modernc.org/sqlite" "github.com/moond4rk/hackbrowserdata/crypto" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) // key4DB holds the parsed contents of Firefox's key4.db NSS key storage. diff --git a/browser/firefox/profile.go b/browser/firefox/profile.go index ad362931..eee4fcb1 100644 --- a/browser/firefox/profile.go +++ b/browser/firefox/profile.go @@ -5,7 +5,7 @@ import ( "path/filepath" "github.com/moond4rk/hackbrowserdata/filemanager" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/fileutil" ) diff --git a/browser/keydump.go b/browser/keydump.go index a16fba03..0d90298b 100644 --- a/browser/keydump.go +++ b/browser/keydump.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/safari/extract_history.go b/browser/safari/extract_history.go index 28cb2425..736abfe2 100644 --- a/browser/safari/extract_history.go +++ b/browser/safari/extract_history.go @@ -4,8 +4,9 @@ import ( "database/sql" "sort" + "github.com/moond4rk/hackbrowserdata/internal/sqlitex" + "github.com/moond4rk/hackbrowserdata/internal/timeconv" "github.com/moond4rk/hackbrowserdata/types" - "github.com/moond4rk/hackbrowserdata/utils/sqliteutil" ) const ( @@ -24,7 +25,7 @@ const ( ) func extractHistories(path string) ([]types.HistoryEntry, error) { - histories, err := sqliteutil.QueryRows(path, true, safariHistoryQuery, + histories, err := sqlitex.QueryRows(path, true, safariHistoryQuery, func(rows *sql.Rows) (types.HistoryEntry, error) { var ( url, title string @@ -38,7 +39,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { URL: url, Title: title, VisitCount: visitCount, - LastVisit: coredataTimestamp(visitTime), + LastVisit: timeconv.CoreData(visitTime), }, nil }) if err != nil { @@ -52,5 +53,5 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { } func countHistories(path string) (int, error) { - return sqliteutil.CountRows(path, true, safariCountHistoryQuery) + return sqlitex.CountRows(path, true, safariCountHistoryQuery) } diff --git a/browser/safari/extract_history_test.go b/browser/safari/extract_history_test.go index e00338c5..fd13ead2 100644 --- a/browser/safari/extract_history_test.go +++ b/browser/safari/extract_history_test.go @@ -93,14 +93,3 @@ func TestExtractHistories_NullTitle(t *testing.T) { assert.Equal(t, "https://null.test", got[0].URL) assert.Empty(t, got[0].Title) } - -func TestCoredataTimestamp(t *testing.T) { - // A zero Core Data value is treated as "no timestamp" and returns - // the zero time.Time rather than literal 2001-01-01 — matches the - // convention used by the Chromium and Firefox helpers. - assert.True(t, coredataTimestamp(0).IsZero()) - - // Known value: 700000000 Core Data = 1678307200 Unix - ts2 := coredataTimestamp(700000000) - assert.Equal(t, int64(1678307200), ts2.Unix()) -} diff --git a/browser/safari/extract_password.go b/browser/safari/extract_password.go index cb3d3f66..f1dc1b26 100644 --- a/browser/safari/extract_password.go +++ b/browser/safari/extract_password.go @@ -7,7 +7,7 @@ import ( "github.com/moond4rk/keychainbreaker" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/safari/extract_storage.go b/browser/safari/extract_storage.go index cd88316d..b5c2f297 100644 --- a/browser/safari/extract_storage.go +++ b/browser/safari/extract_storage.go @@ -10,7 +10,7 @@ import ( _ "modernc.org/sqlite" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/safari/profile.go b/browser/safari/profile.go index 98bc2461..fab95574 100644 --- a/browser/safari/profile.go +++ b/browser/safari/profile.go @@ -4,7 +4,7 @@ import ( "path/filepath" "github.com/moond4rk/hackbrowserdata/filemanager" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/browser/safari/profiles.go b/browser/safari/profiles.go index 69ac49d6..12ec4449 100644 --- a/browser/safari/profiles.go +++ b/browser/safari/profiles.go @@ -10,7 +10,7 @@ import ( _ "modernc.org/sqlite" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) // profileContext tracks the uppercase (Safari/Profiles/) and lowercase diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 6c6645c5..34ac2f59 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -2,7 +2,6 @@ package safari import ( "os" - "time" "github.com/moond4rk/hackbrowserdata/types" ) @@ -103,23 +102,3 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath) map[types.Categ } return resolved } - -// Offset from the Core Data epoch (2001-01-01 UTC) to the Unix epoch. -const coreDataEpochOffset = 978307200 - -// maxCoreDataSeconds guards against CFAbsoluteTime values that would exceed -// time.Time.MarshalJSON's year-9999 ceiling, and bounds the float→int64 -// conversion below (Go spec: out-of-range result is implementation-dependent). -const maxCoreDataSeconds = 252423993600 - -// coredataTimestamp converts Core Data seconds (CFAbsoluteTime) to UTC. -// Returns zero for non-positive input or out-of-JSON-range values. -func coredataTimestamp(seconds float64) time.Time { - if seconds <= 0 || seconds > maxCoreDataSeconds { - return time.Time{} - } - whole := int64(seconds) - frac := seconds - float64(whole) - nanos := int64(frac * 1e9) - return time.Unix(whole+coreDataEpochOffset, nanos).UTC() -} diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 4ae34ee1..af6d5f37 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -187,32 +186,3 @@ func TestCountEntries(t *testing.T) { require.Len(t, results, 1) assert.Equal(t, 2, results[0].Counts[types.History]) } - -// Anchor: 2024-01-15T10:30:00Z, in seconds past the Core Data epoch (2001-01-01Z). -const anchorCoreDataSeconds = 1705314600 - 978307200 - -func TestCoredataTimestamp_AnchorDate(t *testing.T) { - got := coredataTimestamp(float64(anchorCoreDataSeconds)) - want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) - assert.Equal(t, want, got) -} - -func TestCoredataTimestamp_EpochZero(t *testing.T) { - assert.True(t, coredataTimestamp(0).IsZero()) -} - -func TestCoredataTimestamp_NegativeReturnsZeroTime(t *testing.T) { - assert.True(t, coredataTimestamp(-1).IsZero()) -} - -func TestCoredataTimestamp_FractionalSecondsPreserved(t *testing.T) { - got := coredataTimestamp(float64(anchorCoreDataSeconds) + 0.5) - assert.Equal(t, 500*int64(time.Millisecond), int64(got.Nanosecond())) -} - -func TestCoredataTimestamp_AlwaysUTC(t *testing.T) { - // assert.Same: pointer equality reliably catches any regression that - // leaks time.Local, independent of the runner's configured TZ. - got := coredataTimestamp(float64(anchorCoreDataSeconds)) - assert.Same(t, time.UTC, got.Location()) -} diff --git a/cmd/hack-browser-data/archive.go b/cmd/hack-browser-data/archive.go index 8ca7012a..0cf75b18 100644 --- a/cmd/hack-browser-data/archive.go +++ b/cmd/hack-browser-data/archive.go @@ -4,7 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) func archiveCmd() *cobra.Command { diff --git a/cmd/hack-browser-data/dump.go b/cmd/hack-browser-data/dump.go index ee30220a..2940e976 100644 --- a/cmd/hack-browser-data/dump.go +++ b/cmd/hack-browser-data/dump.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/cmd/hack-browser-data/dumpkeys.go b/cmd/hack-browser-data/dumpkeys.go index f9041fe0..a778c68f 100644 --- a/cmd/hack-browser-data/dumpkeys.go +++ b/cmd/hack-browser-data/dumpkeys.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) func dumpKeysCmd() *cobra.Command { diff --git a/cmd/hack-browser-data/extract.go b/cmd/hack-browser-data/extract.go index cee29bb1..df839b43 100644 --- a/cmd/hack-browser-data/extract.go +++ b/cmd/hack-browser-data/extract.go @@ -5,7 +5,7 @@ import ( "path/filepath" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/output" "github.com/moond4rk/hackbrowserdata/types" "github.com/moond4rk/hackbrowserdata/utils/fileutil" diff --git a/cmd/hack-browser-data/main.go b/cmd/hack-browser-data/main.go index a632f1fe..071cd480 100644 --- a/cmd/hack-browser-data/main.go +++ b/cmd/hack-browser-data/main.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) var verbose bool diff --git a/cmd/hack-browser-data/restore.go b/cmd/hack-browser-data/restore.go index 4c2b9282..ea174261 100644 --- a/cmd/hack-browser-data/restore.go +++ b/cmd/hack-browser-data/restore.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/moond4rk/hackbrowserdata/browser" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/masterkey" "github.com/moond4rk/hackbrowserdata/utils/fileutil" ) diff --git a/log/level.go b/internal/log/level.go similarity index 100% rename from log/level.go rename to internal/log/level.go diff --git a/log/log.go b/internal/log/log.go similarity index 100% rename from log/log.go rename to internal/log/log.go diff --git a/log/log_test.go b/internal/log/log_test.go similarity index 100% rename from log/log_test.go rename to internal/log/log_test.go diff --git a/log/logger.go b/internal/log/logger.go similarity index 100% rename from log/logger.go rename to internal/log/logger.go diff --git a/log/logger_test.go b/internal/log/logger_test.go similarity index 100% rename from log/logger_test.go rename to internal/log/logger_test.go diff --git a/utils/sqliteutil/query.go b/internal/sqlitex/query.go similarity index 81% rename from utils/sqliteutil/query.go rename to internal/sqlitex/query.go index 3ff6c54c..a819a64b 100644 --- a/utils/sqliteutil/query.go +++ b/internal/sqlitex/query.go @@ -1,4 +1,4 @@ -package sqliteutil +package sqlitex import ( "database/sql" @@ -7,7 +7,7 @@ import ( ) // CountRows runs a scalar count query (e.g. SELECT COUNT(*) FROM ...) and -// returns the integer result. Unlike QuerySQLite (which swallows per-row scan +// returns the integer result. Unlike eachRow (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 { @@ -32,14 +32,14 @@ func CountRows(dbPath string, journalOff bool, query string) (int, error) { return count, nil } -// QueryRows is a generic helper (Go 1.18+) that wraps QuerySQLite and collects +// QueryRows is a generic helper (Go 1.18+) that wraps eachRow and collects // results into a typed slice. Each extract method only needs to provide the // scan function that converts one database row into a typed value. // -// Rows that fail to scan are skipped (logged at debug level by QuerySQLite). +// Rows that fail to scan are skipped (logged at debug level by eachRow). func QueryRows[T any](dbPath string, journalOff bool, query string, scanRow func(*sql.Rows) (T, error)) ([]T, error) { var items []T - err := QuerySQLite(dbPath, journalOff, query, func(rows *sql.Rows) error { + err := eachRow(dbPath, journalOff, query, func(rows *sql.Rows) error { item, err := scanRow(rows) if err != nil { return err diff --git a/utils/sqliteutil/sqlite.go b/internal/sqlitex/sqlite.go similarity index 79% rename from utils/sqliteutil/sqlite.go rename to internal/sqlitex/sqlite.go index f367031c..082fac06 100644 --- a/utils/sqliteutil/sqlite.go +++ b/internal/sqlitex/sqlite.go @@ -1,4 +1,4 @@ -package sqliteutil +package sqlitex import ( "database/sql" @@ -8,10 +8,10 @@ import ( // sqlite3 driver for database/sql _ "modernc.org/sqlite" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) -// QuerySQLite opens a SQLite database, optionally disables journal mode (required +// eachRow opens a SQLite database, optionally disables journal mode (required // for Firefox databases), runs the query, and calls scanFn for each row. // // It validates the database file exists before opening to prevent sql.Open from @@ -19,7 +19,7 @@ import ( // // scanFn should return nil to continue iteration, or an error to skip the current // row (the error is logged at debug level and iteration continues). -func QuerySQLite(dbPath string, journalOff bool, query string, scanFn func(*sql.Rows) error) error { +func eachRow(dbPath string, journalOff bool, query string, scanFn func(*sql.Rows) error) error { if _, err := os.Stat(dbPath); err != nil { return fmt.Errorf("database file: %w", err) } diff --git a/utils/sqliteutil/sqlite_test.go b/internal/sqlitex/sqlite_test.go similarity index 88% rename from utils/sqliteutil/sqlite_test.go rename to internal/sqlitex/sqlite_test.go index 01825741..6d61bde2 100644 --- a/utils/sqliteutil/sqlite_test.go +++ b/internal/sqlitex/sqlite_test.go @@ -1,4 +1,4 @@ -package sqliteutil +package sqlitex import ( "database/sql" @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestQuerySQLite(t *testing.T) { +func TestEachRow(t *testing.T) { // Create a temp SQLite database dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") @@ -25,7 +25,7 @@ func TestQuerySQLite(t *testing.T) { // Query using our helper var names []string - err = QuerySQLite(dbPath, false, "SELECT name FROM items ORDER BY id", func(rows *sql.Rows) error { + err = eachRow(dbPath, false, "SELECT name FROM items ORDER BY id", func(rows *sql.Rows) error { var name string if err := rows.Scan(&name); err != nil { return err @@ -38,7 +38,7 @@ func TestQuerySQLite(t *testing.T) { assert.Equal(t, []string{"alpha", "beta", "gamma"}, names) } -func TestQuerySQLite_JournalOff(t *testing.T) { +func TestEachRow_JournalOff(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") @@ -51,7 +51,7 @@ func TestQuerySQLite_JournalOff(t *testing.T) { require.NoError(t, db.Close()) var values []string - err = QuerySQLite(dbPath, true, "SELECT v FROM t", func(rows *sql.Rows) error { + err = eachRow(dbPath, true, "SELECT v FROM t", func(rows *sql.Rows) error { var v string if err := rows.Scan(&v); err != nil { return err @@ -63,14 +63,14 @@ func TestQuerySQLite_JournalOff(t *testing.T) { assert.Equal(t, []string{"ok"}, values) } -func TestQuerySQLite_FileNotFound(t *testing.T) { - err := QuerySQLite("/nonexistent/path.db", false, "SELECT 1", func(rows *sql.Rows) error { +func TestEachRow_FileNotFound(t *testing.T) { + err := eachRow("/nonexistent/path.db", false, "SELECT 1", func(rows *sql.Rows) error { return nil }) require.Error(t, err) } -func TestQuerySQLite_BadQuery(t *testing.T) { +func TestEachRow_BadQuery(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") @@ -80,7 +80,7 @@ func TestQuerySQLite_BadQuery(t *testing.T) { require.NoError(t, err) require.NoError(t, db.Close()) - err = QuerySQLite(dbPath, false, "SELECT nonexistent FROM t", func(rows *sql.Rows) error { + err = eachRow(dbPath, false, "SELECT nonexistent FROM t", func(rows *sql.Rows) error { return nil }) require.Error(t, err) diff --git a/internal/timeconv/timeconv.go b/internal/timeconv/timeconv.go new file mode 100644 index 00000000..6b802b8a --- /dev/null +++ b/internal/timeconv/timeconv.go @@ -0,0 +1,60 @@ +// Package timeconv converts browser timestamps to UTC; browsers write 0 for "unset", so non-positive input is the zero time. +package timeconv + +import "time" + +const ( + // Chromium's base::Time::kTimeTToMicrosecondsOffset, from 1601-01-01 to the Unix epoch. + chromiumEpochOffsetMicros = 11644473600000000 + coreDataEpochOffset = 978307200 + // Keeps CoreData inside int64 and below year 9999 before the float conversion. + maxCoreDataSeconds = 252423993600 +) + +// Chromium converts base::Time, microseconds since 1601-01-01 UTC. +func Chromium(us int64) time.Time { + if us <= 0 { + return time.Time{} + } + return jsonSafe(time.UnixMicro(us - chromiumEpochOffsetMicros).UTC()) +} + +func UnixMicro(us int64) time.Time { + if us <= 0 { + return time.Time{} + } + return jsonSafe(time.UnixMicro(us).UTC()) +} + +func UnixMilli(ms int64) time.Time { + if ms <= 0 { + return time.Time{} + } + return jsonSafe(time.UnixMilli(ms).UTC()) +} + +func Unix(s int64) time.Time { + if s <= 0 { + return time.Time{} + } + return jsonSafe(time.Unix(s, 0).UTC()) +} + +// CoreData converts CFAbsoluteTime, seconds since 2001-01-01 UTC. +func CoreData(s float64) time.Time { + if s <= 0 || s > maxCoreDataSeconds { + return time.Time{} + } + whole := int64(s) + frac := s - float64(whole) + nanos := int64(frac * 1e9) + return time.Unix(whole+coreDataEpochOffset, nanos).UTC() +} + +// time.Time.MarshalJSON rejects years outside [1, 9999], so such values export as the zero time. +func jsonSafe(t time.Time) time.Time { + if t.Year() < 1 || t.Year() > 9999 { + return time.Time{} + } + return t +} diff --git a/internal/timeconv/timeconv_test.go b/internal/timeconv/timeconv_test.go new file mode 100644 index 00000000..4f1525e1 --- /dev/null +++ b/internal/timeconv/timeconv_test.go @@ -0,0 +1,143 @@ +package timeconv + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Anchor: 2024-01-15T10:30:00Z. +const anchorUnixSeconds = int64(1705314600) + +const anchorCoreDataSeconds = anchorUnixSeconds - 978307200 + +var ( + anchorTime = time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + anchorChromiumMicros = (anchorUnixSeconds + 11644473600) * 1_000_000 +) + +func TestChromium_AnchorDate(t *testing.T) { + got := Chromium(anchorChromiumMicros) + assert.Equal(t, anchorTime, got) + assert.Equal(t, anchorUnixSeconds, got.Unix()) +} + +func TestChromium_ZeroReturnsZeroTime(t *testing.T) { + assert.True(t, Chromium(0).IsZero()) +} + +func TestChromium_NegativeReturnsZeroTime(t *testing.T) { + assert.True(t, Chromium(-1).IsZero()) +} + +func TestChromium_AlwaysUTC(t *testing.T) { + // assert.Same compares pointers, so it catches a dropped .UTC() even when the runner's TZ is UTC. + assert.Same(t, time.UTC, Chromium(anchorChromiumMicros).Location()) +} + +func TestChromium_MicrosecondPrecisionPreserved(t *testing.T) { + got := Chromium(anchorChromiumMicros + 123456) + assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) +} + +func TestChromium_UnixEpochBoundary(t *testing.T) { + assert.Equal(t, time.Unix(0, 0).UTC(), Chromium(chromiumEpochOffsetMicros)) +} + +func TestChromium_OutOfJSONRangeReturnsZero(t *testing.T) { + jsonBytes, err := Chromium(1 << 62).MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes)) +} + +func TestUnixMicro_AnchorDate(t *testing.T) { + assert.Equal(t, anchorTime, UnixMicro(anchorUnixSeconds*1_000_000)) +} + +func TestUnixMicro_PrecisionPreserved(t *testing.T) { + got := UnixMicro(anchorUnixSeconds*1_000_000 + 123456) + assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) +} + +func TestUnixMilli_AnchorDate(t *testing.T) { + assert.Equal(t, anchorTime, UnixMilli(anchorUnixSeconds*1_000)) +} + +func TestUnixMilli_PrecisionPreserved(t *testing.T) { + got := UnixMilli(anchorUnixSeconds*1_000 + 789) + assert.Equal(t, 789*int64(time.Millisecond), int64(got.Nanosecond())) +} + +func TestUnix_AnchorDate(t *testing.T) { + assert.Equal(t, anchorTime, Unix(anchorUnixSeconds)) +} + +func TestUnixUnits_ZeroReturnsZeroTime(t *testing.T) { + assert.True(t, UnixMicro(0).IsZero(), "micros") + assert.True(t, UnixMilli(0).IsZero(), "millis") + assert.True(t, Unix(0).IsZero(), "seconds") +} + +func TestUnixUnits_NegativeReturnsZeroTime(t *testing.T) { + assert.True(t, UnixMicro(-1).IsZero(), "micros") + assert.True(t, UnixMilli(-1).IsZero(), "millis") + assert.True(t, Unix(-1).IsZero(), "seconds") +} + +func TestUnixUnits_AlwaysUTC(t *testing.T) { + assert.Same(t, time.UTC, UnixMicro(anchorUnixSeconds*1_000_000).Location()) + assert.Same(t, time.UTC, UnixMilli(anchorUnixSeconds*1_000).Location()) + assert.Same(t, time.UTC, Unix(anchorUnixSeconds).Location()) +} + +func TestUnixUnits_SameMomentAcrossUnits(t *testing.T) { + us := UnixMicro(anchorUnixSeconds * 1_000_000) + ms := UnixMilli(anchorUnixSeconds * 1_000) + s := Unix(anchorUnixSeconds) + assert.True(t, us.Equal(ms)) + assert.True(t, ms.Equal(s)) +} + +func TestUnixUnits_OutOfJSONRangeReturnsZero(t *testing.T) { + for _, tc := range []struct { + name string + got time.Time + }{ + {"seconds", Unix(1 << 50)}, + {"millis", UnixMilli(1 << 60)}, + {"micros", UnixMicro(1 << 62)}, + } { + t.Run(tc.name, func(t *testing.T) { + b, err := tc.got.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(b)) + }) + } +} + +func TestCoreData_AnchorDate(t *testing.T) { + assert.Equal(t, anchorTime, CoreData(float64(anchorCoreDataSeconds))) +} + +func TestCoreData_KnownValue(t *testing.T) { + assert.Equal(t, int64(1678307200), CoreData(700000000).Unix()) +} + +func TestCoreData_EpochZero(t *testing.T) { + assert.True(t, CoreData(0).IsZero()) +} + +func TestCoreData_NegativeReturnsZeroTime(t *testing.T) { + assert.True(t, CoreData(-1).IsZero()) +} + +func TestCoreData_FractionalSecondsPreserved(t *testing.T) { + got := CoreData(float64(anchorCoreDataSeconds) + 0.5) + assert.Equal(t, 500*int64(time.Millisecond), int64(got.Nanosecond())) +} + +func TestCoreData_AlwaysUTC(t *testing.T) { + assert.Same(t, time.UTC, CoreData(float64(anchorCoreDataSeconds)).Location()) +} diff --git a/masterkey/abe_windows.go b/masterkey/abe_windows.go index e0952676..020efad6 100644 --- a/masterkey/abe_windows.go +++ b/masterkey/abe_windows.go @@ -12,7 +12,7 @@ import ( "github.com/tidwall/gjson" "github.com/moond4rk/hackbrowserdata/crypto/windows/payload" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/utils/injector" "github.com/moond4rk/hackbrowserdata/utils/winutil" ) diff --git a/masterkey/retriever.go b/masterkey/retriever.go index 6ed14da5..e1a90fb7 100644 --- a/masterkey/retriever.go +++ b/masterkey/retriever.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) // errStorageNotFound: the browser's account is absent from the credential store (keychain/keyring). diff --git a/masterkey/retriever_darwin.go b/masterkey/retriever_darwin.go index 8f7711f3..c0fc4a51 100644 --- a/masterkey/retriever_darwin.go +++ b/masterkey/retriever_darwin.go @@ -15,7 +15,7 @@ import ( "github.com/moond4rk/keychainbreaker" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" ) // https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_mac.mm;l=157 diff --git a/output/output.go b/output/output.go index d9621187..ed98cf65 100644 --- a/output/output.go +++ b/output/output.go @@ -16,7 +16,7 @@ import ( "path/filepath" "strings" - "github.com/moond4rk/hackbrowserdata/log" + "github.com/moond4rk/hackbrowserdata/internal/log" "github.com/moond4rk/hackbrowserdata/types" ) diff --git a/rfcs/004-naming-decisions.md b/rfcs/004-naming-decisions.md index a92331d4..2ff3c17a 100644 --- a/rfcs/004-naming-decisions.md +++ b/rfcs/004-naming-decisions.md @@ -33,6 +33,7 @@ Internal Go names are free to change; anything the user can see — commands, fl | `app` stages and values | `Validate`, `Discover`, `NewPlan`, `Execute`, `Report`; `Request`, `Discovery`, `Plan`, `Extraction`, `Summary`. | | `snapshot.Dir` / `snapshot.Snapshot` | A snapshot directory and one user data dir's snapshot inside it, a snapshot being one user data dir's copy as in Chromium; not `FS`, `Tree`, or `Copy`. | | `winapi`, `abe`; `log` and `crypto` kept | Windows-only packages; not `platform/windows`. | +| `timeconv` and `sqlitex` functions | `timeconv.Chromium`, `UnixMicro`, `UnixMilli`, `Unix`, `CoreData`, each named for the encoding it reads; `sqlitex` exports `CountRows` and `QueryRows` only. | | CLI names unchanged | `-c/--category` selects `category.Category` values; `-p/--profile-path` and `dumpkeys` stay. [RFC-009](009-cli-surface.md). | ## Item-by-item detail @@ -229,6 +230,12 @@ A category says what kind of data is meant; a record is one row of that data. Th | `keychain.Login` | collides with `record.Login`, a saved login. | | `masterkey.Keychain` | `masterkey` holds Chromium keys; Safari's saved logins are not keys. | +### 9. Leaf helpers — `timeconv` and `sqlitex` + +**`timeconv`: one function per stored encoding, named for what the caller holds.** `Chromium` reads `base::Time`, microseconds since 1601; `CoreData` reads `CFAbsoluteTime`, seconds since 2001; `UnixMicro`, `UnixMilli`, and `Unix` read the three Unix units Firefox stores, named after the `time` functions they wrap. Not `WebKit`: in this tool WebKit is Safari's engine, and Safari's timestamps are Core Data's. + +**`sqlitex` exports `CountRows` and `QueryRows` only.** The row-callback helper behind them, today's `sqliteutil.QuerySQLite`, has no other caller, and as an export it would read `sqlitex.QuerySQLite`. + ## Summary table | Concept | Decision | Rejected | @@ -248,5 +255,6 @@ A category says what kind of data is meant; a record is one row of that data. Th | Unit of work | `app.Extraction{DirID, Profile, Category}` | `Job`, `Task`, `Target`, `Unit` | | Snapshot | `snapshot.Dir` + `snapshot.Snapshot`, returned by `Dir.Snapshot(dirID)` | `snapshot.FS`, `snapshot.Tree`, `snapshot.Copy`, `snapshot.Files`, `Dir.CopyOf`, `type Dir string` | | Windows packages | `winapi`, `abe` | `platform/windows` | +| Leaf helpers | `timeconv.Chromium`…`CoreData`; `sqlitex.CountRows`, `QueryRows` | `timeconv.WebKit`, an exported `sqlitex.QuerySQLite` | The only cross-row couplings: the `category` package and the bare record names are one decision, and the flat `Browser`/`Profile` source replaces every mention of `Origin`. diff --git a/rfcs/011-testing-and-quality.md b/rfcs/011-testing-and-quality.md index 70666418..c337fdac 100644 --- a/rfcs/011-testing-and-quality.md +++ b/rfcs/011-testing-and-quality.md @@ -31,7 +31,7 @@ These guard the contract; a failure means the rewrite changed something users ca Export and extractor tests compare against checked-in golden outputs. Deterministic ordering is what makes goldens stable — without it, goldens would flap. A `-update` flag regenerates them on intentional changes. Goldens double as documentation of the exact output shape, useful for the README and for talks. -The compatibility goldens — the flag sets, every output format, `keys.json`, and the archive layout — live in the repository-root `testdata/` and are read by the tests in `cmd/hack-browser-data`, the one package that never moves, so every phase compares its output against the same files. A golden holds what no other golden already implies: the Cookie-Editor golden holds only `cookie.json`, because every other category is asserted to match the json output byte for byte, and `restore` is asserted to match the extract output except for its `browser` stamp. +The compatibility goldens — the flag sets, every output format, `keys.json`, and the archive layout — live in the repository-root `testdata/` and are read by the tests in `cmd/hack-browser-data`, the one package that never moves, so every phase compares its output against the same files. After the switch-over, Phase 8 moves each test and its goldens into the package it covers ([RFC-012](012-migration-roadmap.md)). A golden holds what no other golden already implies: the Cookie-Editor golden holds only `cookie.json`, because every other category is asserted to match the json output byte for byte, and `restore` is asserted to match the extract output except for its `browser` stamp. ## Regression tests for the known bugs diff --git a/rfcs/012-migration-roadmap.md b/rfcs/012-migration-roadmap.md index adaf4577..f03805fe 100644 --- a/rfcs/012-migration-roadmap.md +++ b/rfcs/012-migration-roadmap.md @@ -62,6 +62,7 @@ Leaves first, bottom-up along the dependency arrows ([RFC-002](002-package-layou ### Phase 8 — Delete & document - Remove the superseded top-level packages — `browser/`, `crypto/` except the ABE unit's `crypto/windows/`, `filemanager/`, `log/`, `masterkey/`, `output/`, `types/`, and `utils/` except `utils/injector` — once nothing references them. +- Move the compatibility tests out of `cmd/hack-browser-data` into the packages they cover — the export goldens into `export`, `keys.json` into `masterkey`, the archive into `snapshot`, the extract goldens into `browser/chromium`, and `restore` into `app` — each with its goldens in its own `testdata/`; `cmd` keeps only the flag-surface test, and the root `testdata/` goes away. - Update CLAUDE.md (the new paths), the README (Safari, the `cookie-editor` behavior), and the root help text. - Bring the RFCs to their finished state: the format and technique RFCs (013–021) move from today's type names — `BrowserConfig`, `WindowsABE`, `winutil.Table`, `Hints`, the `Retrievers` struct — to the names in [RFC-004](004-naming-decisions.md); the design RFCs describe the code as it is instead of comparing it with today's; and this roadmap is removed. - Final `go mod tidy` under go@1.20; confirm the `go` directive is still `1.20` and `otiai10/copy` and `mint` are gone.