From 410700ac70f34efa3777b24b7d9ac48a28a76f7f Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Thu, 23 Apr 2026 18:38:47 +0800 Subject: [PATCH 1/5] fix(time): correct Chromium/Firefox/Safari timestamp conversions Replaces broken per-browser helpers that used time.Local (causing up to 8h + historical LMT drift for UTC+8 machines), truncated sub-second precision via integer division, and returned arbitrary sentinel dates (2049-01-01, 9999-12-13) for out-of-range values. - Chromium timeEpoch: single UnixMicro subtraction with the canonical offset 11644473600000000 (matches base::Time::kTimeTToMicrosecondsOffset). - Firefox: splits the catch-all timestamp() into firefoxMicros/Millis/Seconds, matching each column's actual unit; callers drop manual division. - Safari: adds .UTC() and preserves fractional seconds from CFAbsoluteTime. - All helpers return zero time.Time for non-positive input and clamp values outside [year 1, year 9999] so JSON export cannot panic on sentinel values like INT64_MAX. Unit tests cover UTC invariance (with t.Setenv TZ), precision preservation, and JSON round-trip safety. Validated end-to-end on the Windows regression sandbox: 726 cookies, 0 non-UTC entries, 0 LMT drift, 716 preserved microsecond precision. Fixes #239, #240, #522 --- browser/chromium/chromium.go | 20 +++--- browser/chromium/chromium_test.go | 60 ++++++++++++++++++ browser/firefox/extract_bookmark.go | 2 +- browser/firefox/extract_cookie.go | 4 +- browser/firefox/extract_download.go | 4 +- browser/firefox/extract_history.go | 2 +- browser/firefox/extract_password.go | 2 +- browser/firefox/firefox.go | 39 ++++++++++-- browser/firefox/firefox_test.go | 88 ++++++++++++++++++++++++++ browser/safari/extract_history_test.go | 7 +- browser/safari/safari.go | 16 ++++- browser/safari/safari_test.go | 38 +++++++++++ 12 files changed, 255 insertions(+), 27 deletions(-) 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..87efd848 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,62 @@ func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) { SetKeyRetrievers(keyretriever.Retrievers) } = (*Browser)(nil) } + +// --------------------------------------------------------------------------- +// timeEpoch +// --------------------------------------------------------------------------- + +// Anchor: 2024-01-15T10:30:00Z = Unix seconds 1705314600. Chromium stores +// this as microseconds since 1601-01-01 UTC, so the stored value is +// (1705314600 + 11644473600) * 1e6. +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) { + // Chromium uses 0 for session cookies (no persistent expiry). + assert.True(t, timeEpoch(0).IsZero()) +} + +func TestTimeEpoch_NegativeReturnsZeroTime(t *testing.T) { + // -1 is the legacy "never expires" sentinel on older profiles. + assert.True(t, timeEpoch(-1).IsZero()) +} + +func TestTimeEpoch_AlwaysUTC(t *testing.T) { + // Location must be UTC regardless of the test runner's TZ. Set a + // non-UTC local zone via t.Setenv so the assertion catches any + // accidental time.Local usage. + t.Setenv("TZ", "Asia/Shanghai") + got := timeEpoch(anchorChromiumMicros) + assert.Equal(t, time.UTC, got.Location()) +} + +func TestTimeEpoch_MicrosecondPrecisionPreserved(t *testing.T) { + // Add 123456 μs to the anchor and confirm the nanosecond component + // survives the conversion (no silent truncation to seconds). + got := timeEpoch(anchorChromiumMicros + 123456) + assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) +} + +func TestTimeEpoch_UnixEpochBoundary(t *testing.T) { + // Exactly the offset constant → 1970-01-01T00:00:00 UTC. + got := timeEpoch(chromiumEpochOffsetMicros) + assert.Equal(t, time.Unix(0, 0).UTC(), got) +} + +func TestTimeEpoch_OutOfJSONRangeReturnsZero(t *testing.T) { + // Some sites write nonsense "never expires" sentinels that compute + // to years past 9999. time.Time.MarshalJSON would crash on those; + // the helper must defensively return zero so JSON export works. + jsonBytes, err := timeEpoch(1 << 62).MarshalJSON() + assert.NoError(t, err) + assert.Equal(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..509b6cf7 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 [0, 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..6496a769 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,90 @@ func TestExtractCategory(t *testing.T) { assert.Empty(t, data.SessionStorage) }) } + +// --------------------------------------------------------------------------- +// firefoxMicros / firefoxMillis / firefoxSeconds +// --------------------------------------------------------------------------- + +// Anchor: 2024-01-15T10:30:00Z → Unix seconds 1705314600. +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) { + // 123456 μs past the anchor — the sub-second portion must survive. + 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) { + // Verify no helper leaks time.Local, regardless of runner TZ. + t.Setenv("TZ", "Asia/Shanghai") + assert.Equal(t, time.UTC, firefoxMicros(anchorUnixSeconds*1_000_000).Location()) + assert.Equal(t, time.UTC, firefoxMillis(anchorUnixSeconds*1_000).Location()) + assert.Equal(t, time.UTC, firefoxSeconds(anchorUnixSeconds).Location()) +} + +func TestFirefoxHelpers_SameMomentAcrossUnits(t *testing.T) { + // The same wall-clock instant, expressed in three units, must + // produce three equal time.Time values (no unit confusion). + 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) { + // Cookie expiry is occasionally set to INT64_MAX meaning "never"; + // without the clamp, time.Time.MarshalJSON would crash at export. + // All three helpers must return the zero time for such input, so + // JSON round-trip just yields "0001-01-01T00:00:00Z". + 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() + assert.NoError(t, err) + assert.Equal(t, `"0001-01-01T00:00:00Z"`, string(b)) + }) + } +} 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/safari.go b/browser/safari/safari.go index b894102d..7928137a 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -212,9 +212,21 @@ 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 +// 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 { + return time.Time{} + } + whole := int64(seconds) + frac := seconds - float64(whole) + nanos := int64(frac * 1e9) + t := time.Unix(whole+coreDataEpochOffset, nanos).UTC() + if t.Year() < 1 || t.Year() > 9999 { + return time.Time{} + } + return t } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 6cbb5cd7..1866e376 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,40 @@ func TestExtractCategory(t *testing.T) { assert.Empty(t, data.CreditCards) }) } + +// --------------------------------------------------------------------------- +// coredataTimestamp +// --------------------------------------------------------------------------- + +// Anchor: 2024-01-15T10:30:00Z = Unix 1705314600, which is +// 1705314600 - 978307200 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) { + // Core Data "distant past" sentinel (0 seconds) maps to the zero + // time.Time rather than silently returning 2001-01-01. + assert.True(t, coredataTimestamp(0).IsZero()) +} + +func TestCoredataTimestamp_NegativeReturnsZeroTime(t *testing.T) { + assert.True(t, coredataTimestamp(-1).IsZero()) +} + +func TestCoredataTimestamp_FractionalSecondsPreserved(t *testing.T) { + // Core Data is a floating-point double, so fractional seconds are + // real. 0.5s fraction must survive, not silently truncate. + got := coredataTimestamp(float64(anchorCoreDataSeconds) + 0.5) + assert.Equal(t, 500*int64(time.Millisecond), int64(got.Nanosecond())) +} + +func TestCoredataTimestamp_AlwaysUTC(t *testing.T) { + t.Setenv("TZ", "Asia/Shanghai") + got := coredataTimestamp(float64(anchorCoreDataSeconds)) + assert.Equal(t, time.UTC, got.Location()) +} From 5531cfed1a21948a2d0512e240fd69bf5452233c Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Thu, 23 Apr 2026 18:41:40 +0800 Subject: [PATCH 2/5] fix(safari): force UTC on cookie/password time pass-throughs binarycookies and the Keychain shim both return time.Time in Local, so Safari-sourced cookie.expire_at / cookie.created_at / password.created_at leaked +08:00 offsets while Chromium/Firefox exports were already UTC. Observed 115/12353 non-UTC cookies on a real macOS profile; apply .UTC() at the assignment site to match the rest of the codebase. --- browser/safari/extract_cookie.go | 6 ++++-- browser/safari/extract_password.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) 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_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(), }) } From ea0baf937fb278fcd8d5019dbb5b74aa5e7c13c8 Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Thu, 23 Apr 2026 20:26:06 +0800 Subject: [PATCH 3/5] test(time): satisfy testifylint require-error and encoded-compare - Use require.NoError so a MarshalJSON failure aborts the subtest before the follow-up comparison runs on garbage. - Use assert.JSONEq for the JSON-encoded zero-time literal; matches semantic rather than byte-for-byte. --- browser/chromium/chromium_test.go | 4 ++-- browser/firefox/firefox_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 87efd848..15663d4f 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -777,6 +777,6 @@ func TestTimeEpoch_OutOfJSONRangeReturnsZero(t *testing.T) { // to years past 9999. time.Time.MarshalJSON would crash on those; // the helper must defensively return zero so JSON export works. jsonBytes, err := timeEpoch(1 << 62).MarshalJSON() - assert.NoError(t, err) - assert.Equal(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes)) + require.NoError(t, err) + assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes)) } diff --git a/browser/firefox/firefox_test.go b/browser/firefox/firefox_test.go index 6496a769..6019701d 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -398,8 +398,8 @@ func TestFirefoxHelpers_OutOfJSONRangeReturnsZero(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { b, err := tc.got.MarshalJSON() - assert.NoError(t, err) - assert.Equal(t, `"0001-01-01T00:00:00Z"`, string(b)) + require.NoError(t, err) + assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(b)) }) } } From 1c44b560b68602edf59abaefa3de9a8e14e79348 Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Thu, 23 Apr 2026 20:29:58 +0800 Subject: [PATCH 4/5] test(time): drop redundant comments in timestamp tests --- browser/chromium/chromium_test.go | 19 +------------------ browser/firefox/firefox_test.go | 14 +------------- browser/safari/safari_test.go | 11 +---------- 3 files changed, 3 insertions(+), 41 deletions(-) diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 15663d4f..2a9d8a2e 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -722,13 +722,7 @@ func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) { } = (*Browser)(nil) } -// --------------------------------------------------------------------------- -// timeEpoch -// --------------------------------------------------------------------------- - -// Anchor: 2024-01-15T10:30:00Z = Unix seconds 1705314600. Chromium stores -// this as microseconds since 1601-01-01 UTC, so the stored value is -// (1705314600 + 11644473600) * 1e6. +// Anchor: 2024-01-15T10:30:00Z as Chromium microseconds since 1601 UTC. const anchorUnixSeconds = int64(1705314600) var anchorChromiumMicros = (anchorUnixSeconds + 11644473600) * 1_000_000 @@ -741,41 +735,30 @@ func TestTimeEpoch_AnchorDate(t *testing.T) { } func TestTimeEpoch_ZeroReturnsZeroTime(t *testing.T) { - // Chromium uses 0 for session cookies (no persistent expiry). assert.True(t, timeEpoch(0).IsZero()) } func TestTimeEpoch_NegativeReturnsZeroTime(t *testing.T) { - // -1 is the legacy "never expires" sentinel on older profiles. assert.True(t, timeEpoch(-1).IsZero()) } func TestTimeEpoch_AlwaysUTC(t *testing.T) { - // Location must be UTC regardless of the test runner's TZ. Set a - // non-UTC local zone via t.Setenv so the assertion catches any - // accidental time.Local usage. t.Setenv("TZ", "Asia/Shanghai") got := timeEpoch(anchorChromiumMicros) assert.Equal(t, time.UTC, got.Location()) } func TestTimeEpoch_MicrosecondPrecisionPreserved(t *testing.T) { - // Add 123456 μs to the anchor and confirm the nanosecond component - // survives the conversion (no silent truncation to seconds). got := timeEpoch(anchorChromiumMicros + 123456) assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) } func TestTimeEpoch_UnixEpochBoundary(t *testing.T) { - // Exactly the offset constant → 1970-01-01T00:00:00 UTC. got := timeEpoch(chromiumEpochOffsetMicros) assert.Equal(t, time.Unix(0, 0).UTC(), got) } func TestTimeEpoch_OutOfJSONRangeReturnsZero(t *testing.T) { - // Some sites write nonsense "never expires" sentinels that compute - // to years past 9999. time.Time.MarshalJSON would crash on those; - // the helper must defensively return zero so JSON export works. 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/firefox_test.go b/browser/firefox/firefox_test.go index 6019701d..2b19becd 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -317,11 +317,7 @@ func TestExtractCategory(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// firefoxMicros / firefoxMillis / firefoxSeconds -// --------------------------------------------------------------------------- - -// Anchor: 2024-01-15T10:30:00Z → Unix seconds 1705314600. +// Anchor: 2024-01-15T10:30:00Z. const anchorUnixSeconds = int64(1705314600) func TestFirefoxMicros_AnchorDate(t *testing.T) { @@ -331,7 +327,6 @@ func TestFirefoxMicros_AnchorDate(t *testing.T) { } func TestFirefoxMicros_PrecisionPreserved(t *testing.T) { - // 123456 μs past the anchor — the sub-second portion must survive. got := firefoxMicros(anchorUnixSeconds*1_000_000 + 123456) assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond())) } @@ -366,7 +361,6 @@ func TestFirefoxHelpers_NegativeReturnsZeroTime(t *testing.T) { } func TestFirefoxHelpers_AlwaysUTC(t *testing.T) { - // Verify no helper leaks time.Local, regardless of runner TZ. t.Setenv("TZ", "Asia/Shanghai") assert.Equal(t, time.UTC, firefoxMicros(anchorUnixSeconds*1_000_000).Location()) assert.Equal(t, time.UTC, firefoxMillis(anchorUnixSeconds*1_000).Location()) @@ -374,8 +368,6 @@ func TestFirefoxHelpers_AlwaysUTC(t *testing.T) { } func TestFirefoxHelpers_SameMomentAcrossUnits(t *testing.T) { - // The same wall-clock instant, expressed in three units, must - // produce three equal time.Time values (no unit confusion). us := firefoxMicros(anchorUnixSeconds * 1_000_000) ms := firefoxMillis(anchorUnixSeconds * 1_000) s := firefoxSeconds(anchorUnixSeconds) @@ -384,10 +376,6 @@ func TestFirefoxHelpers_SameMomentAcrossUnits(t *testing.T) { } func TestFirefoxHelpers_OutOfJSONRangeReturnsZero(t *testing.T) { - // Cookie expiry is occasionally set to INT64_MAX meaning "never"; - // without the clamp, time.Time.MarshalJSON would crash at export. - // All three helpers must return the zero time for such input, so - // JSON round-trip just yields "0001-01-01T00:00:00Z". for _, tc := range []struct { name string got time.Time diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 1866e376..89c10b62 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -336,12 +336,7 @@ func TestExtractCategory(t *testing.T) { }) } -// --------------------------------------------------------------------------- -// coredataTimestamp -// --------------------------------------------------------------------------- - -// Anchor: 2024-01-15T10:30:00Z = Unix 1705314600, which is -// 1705314600 - 978307200 seconds past the Core Data epoch (2001-01-01Z). +// Anchor: 2024-01-15T10:30:00Z, in seconds past the Core Data epoch (2001-01-01Z). const anchorCoreDataSeconds = 1705314600 - 978307200 func TestCoredataTimestamp_AnchorDate(t *testing.T) { @@ -351,8 +346,6 @@ func TestCoredataTimestamp_AnchorDate(t *testing.T) { } func TestCoredataTimestamp_EpochZero(t *testing.T) { - // Core Data "distant past" sentinel (0 seconds) maps to the zero - // time.Time rather than silently returning 2001-01-01. assert.True(t, coredataTimestamp(0).IsZero()) } @@ -361,8 +354,6 @@ func TestCoredataTimestamp_NegativeReturnsZeroTime(t *testing.T) { } func TestCoredataTimestamp_FractionalSecondsPreserved(t *testing.T) { - // Core Data is a floating-point double, so fractional seconds are - // real. 0.5s fraction must survive, not silently truncate. got := coredataTimestamp(float64(anchorCoreDataSeconds) + 0.5) assert.Equal(t, 500*int64(time.Millisecond), int64(got.Nanosecond())) } From 4b766da299fb568ba4a84fd6d78b3ea6d9d5a224 Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Thu, 23 Apr 2026 20:34:01 +0800 Subject: [PATCH 5/5] fix(time): address Copilot review nits - safari: guard float64 seconds against out-of-range conversion to int64 (Go spec makes those implementation-dependent); bound by maxCoreDataSeconds so year clamp at the end becomes unnecessary. - firefox: correct clampJSON's documented range to [1, 9999] so the comment matches the predicate. - tests: swap assert.Equal for assert.Same on UTC location checks, so any leaked time.Local is caught via pointer identity regardless of the runner's TZ (t.Setenv was ineffective since time.Local is cached at process init). --- browser/chromium/chromium_test.go | 6 ++++-- browser/firefox/firefox.go | 2 +- browser/firefox/firefox_test.go | 9 +++++---- browser/safari/safari.go | 14 ++++++++------ browser/safari/safari_test.go | 5 +++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/browser/chromium/chromium_test.go b/browser/chromium/chromium_test.go index 2a9d8a2e..e884b9a5 100644 --- a/browser/chromium/chromium_test.go +++ b/browser/chromium/chromium_test.go @@ -743,9 +743,11 @@ func TestTimeEpoch_NegativeReturnsZeroTime(t *testing.T) { } func TestTimeEpoch_AlwaysUTC(t *testing.T) { - t.Setenv("TZ", "Asia/Shanghai") + // 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.Equal(t, time.UTC, got.Location()) + assert.Same(t, time.UTC, got.Location()) } func TestTimeEpoch_MicrosecondPrecisionPreserved(t *testing.T) { diff --git a/browser/firefox/firefox.go b/browser/firefox/firefox.go index 509b6cf7..10f20e91 100644 --- a/browser/firefox/firefox.go +++ b/browser/firefox/firefox.go @@ -315,7 +315,7 @@ func firefoxSeconds(s int64) time.Time { return clampJSON(time.Unix(s, 0).UTC()) } -// clampJSON maps years outside time.Time.MarshalJSON's [0, 9999] window +// 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 { diff --git a/browser/firefox/firefox_test.go b/browser/firefox/firefox_test.go index 2b19becd..5836992d 100644 --- a/browser/firefox/firefox_test.go +++ b/browser/firefox/firefox_test.go @@ -361,10 +361,11 @@ func TestFirefoxHelpers_NegativeReturnsZeroTime(t *testing.T) { } func TestFirefoxHelpers_AlwaysUTC(t *testing.T) { - t.Setenv("TZ", "Asia/Shanghai") - assert.Equal(t, time.UTC, firefoxMicros(anchorUnixSeconds*1_000_000).Location()) - assert.Equal(t, time.UTC, firefoxMillis(anchorUnixSeconds*1_000).Location()) - assert.Equal(t, time.UTC, firefoxSeconds(anchorUnixSeconds).Location()) + // 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) { diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 7928137a..039e4524 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -215,18 +215,20 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath) map[types.Categ // 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 { - if seconds <= 0 { + if seconds <= 0 || seconds > maxCoreDataSeconds { return time.Time{} } whole := int64(seconds) frac := seconds - float64(whole) nanos := int64(frac * 1e9) - t := time.Unix(whole+coreDataEpochOffset, nanos).UTC() - if t.Year() < 1 || t.Year() > 9999 { - return time.Time{} - } - return t + return time.Unix(whole+coreDataEpochOffset, nanos).UTC() } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 89c10b62..78278677 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -359,7 +359,8 @@ func TestCoredataTimestamp_FractionalSecondsPreserved(t *testing.T) { } func TestCoredataTimestamp_AlwaysUTC(t *testing.T) { - t.Setenv("TZ", "Asia/Shanghai") + // assert.Same: pointer equality reliably catches any regression that + // leaks time.Local, independent of the runner's configured TZ. got := coredataTimestamp(float64(anchorCoreDataSeconds)) - assert.Equal(t, time.UTC, got.Location()) + assert.Same(t, time.UTC, got.Location()) }