fix(time): correct export data timestamp conversions (#586)

This commit is contained in:
Roger
2026-04-23 20:39:56 +08:00
committed by GitHub
parent 0c6c781567
commit 50c4ea84cb
14 changed files with 228 additions and 30 deletions
+4 -2
View File
@@ -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(),
})
}
}
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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(),
})
}
+16 -2
View File
@@ -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()
}
+30
View File
@@ -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())
}