diff --git a/browser/chromium/chromium.go b/browser/chromium/chromium.go index aa7e96be..50e56776 100644 --- a/browser/chromium/chromium.go +++ b/browser/chromium/chromium.go @@ -348,17 +348,19 @@ func isSkippedDir(name string) bool { return false } -// timeEpoch converts a WebKit/Chromium epoch timestamp (microseconds since -// 1601-01-01) to a time.Time. +// 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 { - maxTime := int64(99633311740000000) - if epoch > maxTime { - return time.Date(2049, 1, 1, 1, 1, 1, 1, time.Local) + if epoch <= 0 { + return time.Time{} } - t := time.Date(1601, 1, 1, 0, 0, 0, 0, time.Local) - d := time.Duration(epoch) - for i := 0; i < 1000; i++ { - t = t.Add(d) + 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 e769b0e7..e884b9a5 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -720,3 +721,47 @@ func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) { SetKeyRetrievers(keyretriever.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/firefox/extract_bookmark.go b/browser/firefox/extract_bookmark.go index 8ed2d6aa..b00215b8 100644 --- a/browser/firefox/extract_bookmark.go +++ b/browser/firefox/extract_bookmark.go @@ -28,7 +28,7 @@ func extractBookmarks(path string) ([]types.BookmarkEntry, error) { Name: title, URL: url, Folder: bookmarkType(bt), - CreatedAt: timestamp(dateAdded / 1000000), + CreatedAt: firefoxMicros(dateAdded), }, nil }) if err != nil { diff --git a/browser/firefox/extract_cookie.go b/browser/firefox/extract_cookie.go index 640acb3a..1d05918c 100644 --- a/browser/firefox/extract_cookie.go +++ b/browser/firefox/extract_cookie.go @@ -36,8 +36,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) { IsHTTPOnly: isHTTPOnly != 0, HasExpire: hasExpire, IsPersistent: hasExpire, - ExpireAt: timestamp(expiry), - CreatedAt: timestamp(createdAt / 1000000), + ExpireAt: firefoxSeconds(expiry), + CreatedAt: firefoxMicros(createdAt), }, nil }) if err != nil { diff --git a/browser/firefox/extract_download.go b/browser/firefox/extract_download.go index febc7dab..c5b63dd4 100644 --- a/browser/firefox/extract_download.go +++ b/browser/firefox/extract_download.go @@ -32,7 +32,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { entry := types.DownloadEntry{ URL: url, - StartTime: timestamp(dateAdded / 1000000), + StartTime: firefoxMicros(dateAdded), } // Firefox stores download metadata as: "target_path,{json}" @@ -42,7 +42,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) { entry.TargetPath = contentList[0] json := "{" + contentList[1] entry.TotalBytes = gjson.Get(json, "fileSize").Int() - entry.EndTime = timestamp(gjson.Get(json, "endTime").Int() / 1000) + entry.EndTime = firefoxMillis(gjson.Get(json, "endTime").Int()) } else { entry.TargetPath = content } diff --git a/browser/firefox/extract_history.go b/browser/firefox/extract_history.go index 58712a77..13502ddc 100644 --- a/browser/firefox/extract_history.go +++ b/browser/firefox/extract_history.go @@ -27,7 +27,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) { URL: url, Title: title, VisitCount: visitCount, - LastVisit: timestamp(lastVisit / 1000000), + LastVisit: firefoxMicros(lastVisit), }, nil }) if err != nil { diff --git a/browser/firefox/extract_password.go b/browser/firefox/extract_password.go index ab422a19..b881b08e 100644 --- a/browser/firefox/extract_password.go +++ b/browser/firefox/extract_password.go @@ -68,7 +68,7 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error) URL: url, Username: string(user), Password: string(pwd), - CreatedAt: timestamp(v.Get("timeCreated").Int() / 1000), + CreatedAt: firefoxMillis(v.Get("timeCreated").Int()), }) } if decryptFails > 0 { diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index 450422f8..10f20e91 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -288,11 +288,38 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir stri return resolved } -// timestamp converts a Unix epoch timestamp (seconds) to a time.Time. -func timestamp(stamp int64) time.Time { - s := time.Unix(stamp, 0) - if s.Local().Year() > 9999 { - return time.Date(9999, 12, 13, 23, 59, 59, 0, time.Local) +// 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 s + 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 17cd6d13..5836992d 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -315,3 +316,79 @@ func TestExtractCategory(t *testing.T) { assert.Empty(t, data.SessionStorage) }) } + +// 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/safari/extract_cookie.go b/browser/safari/extract_cookie.go index 2ccfb62b..b06f6942 100644 --- a/browser/safari/extract_cookie.go +++ b/browser/safari/extract_cookie.go @@ -20,6 +20,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) { for _, page := range pages { for _, c := range page.Cookies { hasExpire := !c.Expires.IsZero() + // binarycookies returns time.Time in Local; normalize to UTC + // so exported JSON matches Chromium/Firefox cookie output. cookies = append(cookies, types.CookieEntry{ Host: string(c.Domain), Path: string(c.Path), @@ -29,8 +31,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) { IsHTTPOnly: c.HTTPOnly, HasExpire: hasExpire, IsPersistent: hasExpire, - ExpireAt: c.Expires, - CreatedAt: c.Creation, + ExpireAt: c.Expires.UTC(), + CreatedAt: c.Creation.UTC(), }) } } diff --git a/browser/safari/extract_history_test.go b/browser/safari/extract_history_test.go index fd618db5..e00338c5 100644 --- a/browser/safari/extract_history_test.go +++ b/browser/safari/extract_history_test.go @@ -95,9 +95,10 @@ func TestExtractHistories_NullTitle(t *testing.T) { } func TestCoredataTimestamp(t *testing.T) { - // 0 Core Data epoch = 2001-01-01 00:00:00 UTC = Unix 978307200 - ts := coredataTimestamp(0) - assert.Equal(t, int64(978307200), ts.Unix()) + // 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) diff --git a/browser/safari/extract_password.go b/browser/safari/extract_password.go index 89bb5ea7..910ac4ec 100644 --- a/browser/safari/extract_password.go +++ b/browser/safari/extract_password.go @@ -27,7 +27,7 @@ func extractPasswords(keychainPassword string) ([]types.LoginEntry, error) { URL: url, Username: p.Account, Password: p.PlainPassword, - CreatedAt: p.Created, + CreatedAt: p.Created.UTC(), }) } diff --git a/browser/safari/safari.go b/browser/safari/safari.go index b894102d..039e4524 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -212,9 +212,23 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath) map[types.Categ return resolved } -// Safari's History.db uses the Core Data epoch (2001-01-01) instead of Unix epoch. +// Offset from the Core Data epoch (2001-01-01 UTC) to the Unix epoch. const coreDataEpochOffset = 978307200 +// maxCoreDataSeconds is the largest CFAbsoluteTime that still lands inside +// time.Time.MarshalJSON's [1, 9999] year window. Also bounds the float → +// int64 conversion below; Go's spec makes out-of-range conversions return +// an implementation-dependent int64, which could silently corrupt results. +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 { - return time.Unix(int64(seconds)+coreDataEpochOffset, 0) + 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 6cbb5cd7..78278677 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -334,3 +335,32 @@ func TestExtractCategory(t *testing.T) { assert.Empty(t, data.CreditCards) }) } + +// 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()) +}