Skip to content

Commit 50c4ea8

Browse files
authored
fix(time): correct export data timestamp conversions (#586)
1 parent 0c6c781 commit 50c4ea8

14 files changed

Lines changed: 228 additions & 30 deletions

browser/chromium/chromium.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -348,17 +348,19 @@ func isSkippedDir(name string) bool {
348348
return false
349349
}
350350

351-
// timeEpoch converts a WebKit/Chromium epoch timestamp (microseconds since
352-
// 1601-01-01) to a time.Time.
351+
// Offset from the Chromium epoch (1601-01-01 UTC) to the Unix epoch,
352+
// matching base::Time::kTimeTToMicrosecondsOffset in Chromium.
353+
const chromiumEpochOffsetMicros int64 = 11644473600000000
354+
355+
// timeEpoch converts a Chromium base::Time (μs since 1601 UTC) to UTC.
356+
// Returns zero for non-positive input or out-of-JSON-range values.
353357
func timeEpoch(epoch int64) time.Time {
354-
maxTime := int64(99633311740000000)
355-
if epoch > maxTime {
356-
return time.Date(2049, 1, 1, 1, 1, 1, 1, time.Local)
358+
if epoch <= 0 {
359+
return time.Time{}
357360
}
358-
t := time.Date(1601, 1, 1, 0, 0, 0, 0, time.Local)
359-
d := time.Duration(epoch)
360-
for i := 0; i < 1000; i++ {
361-
t = t.Add(d)
361+
t := time.UnixMicro(epoch - chromiumEpochOffsetMicros).UTC()
362+
if t.Year() < 1 || t.Year() > 9999 {
363+
return time.Time{}
362364
}
363365
return t
364366
}

browser/chromium/chromium_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
"time"
78

89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
@@ -720,3 +721,47 @@ func TestSetKeyRetrievers_SatisfiesInterface(t *testing.T) {
720721
SetKeyRetrievers(keyretriever.Retrievers)
721722
} = (*Browser)(nil)
722723
}
724+
725+
// Anchor: 2024-01-15T10:30:00Z as Chromium microseconds since 1601 UTC.
726+
const anchorUnixSeconds = int64(1705314600)
727+
728+
var anchorChromiumMicros = (anchorUnixSeconds + 11644473600) * 1_000_000
729+
730+
func TestTimeEpoch_AnchorDate(t *testing.T) {
731+
got := timeEpoch(anchorChromiumMicros)
732+
want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
733+
assert.Equal(t, want, got)
734+
assert.Equal(t, anchorUnixSeconds, got.Unix())
735+
}
736+
737+
func TestTimeEpoch_ZeroReturnsZeroTime(t *testing.T) {
738+
assert.True(t, timeEpoch(0).IsZero())
739+
}
740+
741+
func TestTimeEpoch_NegativeReturnsZeroTime(t *testing.T) {
742+
assert.True(t, timeEpoch(-1).IsZero())
743+
}
744+
745+
func TestTimeEpoch_AlwaysUTC(t *testing.T) {
746+
// assert.Same checks pointer equality: time.UTC and time.Local are
747+
// distinct *Location globals, so this catches any regression that
748+
// drops .UTC() even when the runner's TZ happens to be UTC.
749+
got := timeEpoch(anchorChromiumMicros)
750+
assert.Same(t, time.UTC, got.Location())
751+
}
752+
753+
func TestTimeEpoch_MicrosecondPrecisionPreserved(t *testing.T) {
754+
got := timeEpoch(anchorChromiumMicros + 123456)
755+
assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond()))
756+
}
757+
758+
func TestTimeEpoch_UnixEpochBoundary(t *testing.T) {
759+
got := timeEpoch(chromiumEpochOffsetMicros)
760+
assert.Equal(t, time.Unix(0, 0).UTC(), got)
761+
}
762+
763+
func TestTimeEpoch_OutOfJSONRangeReturnsZero(t *testing.T) {
764+
jsonBytes, err := timeEpoch(1 << 62).MarshalJSON()
765+
require.NoError(t, err)
766+
assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes))
767+
}

browser/firefox/extract_bookmark.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func extractBookmarks(path string) ([]types.BookmarkEntry, error) {
2828
Name: title,
2929
URL: url,
3030
Folder: bookmarkType(bt),
31-
CreatedAt: timestamp(dateAdded / 1000000),
31+
CreatedAt: firefoxMicros(dateAdded),
3232
}, nil
3333
})
3434
if err != nil {

browser/firefox/extract_cookie.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) {
3636
IsHTTPOnly: isHTTPOnly != 0,
3737
HasExpire: hasExpire,
3838
IsPersistent: hasExpire,
39-
ExpireAt: timestamp(expiry),
40-
CreatedAt: timestamp(createdAt / 1000000),
39+
ExpireAt: firefoxSeconds(expiry),
40+
CreatedAt: firefoxMicros(createdAt),
4141
}, nil
4242
})
4343
if err != nil {

browser/firefox/extract_download.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) {
3232

3333
entry := types.DownloadEntry{
3434
URL: url,
35-
StartTime: timestamp(dateAdded / 1000000),
35+
StartTime: firefoxMicros(dateAdded),
3636
}
3737

3838
// Firefox stores download metadata as: "target_path,{json}"
@@ -42,7 +42,7 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) {
4242
entry.TargetPath = contentList[0]
4343
json := "{" + contentList[1]
4444
entry.TotalBytes = gjson.Get(json, "fileSize").Int()
45-
entry.EndTime = timestamp(gjson.Get(json, "endTime").Int() / 1000)
45+
entry.EndTime = firefoxMillis(gjson.Get(json, "endTime").Int())
4646
} else {
4747
entry.TargetPath = content
4848
}

browser/firefox/extract_history.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func extractHistories(path string) ([]types.HistoryEntry, error) {
2727
URL: url,
2828
Title: title,
2929
VisitCount: visitCount,
30-
LastVisit: timestamp(lastVisit / 1000000),
30+
LastVisit: firefoxMicros(lastVisit),
3131
}, nil
3232
})
3333
if err != nil {

browser/firefox/extract_password.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func extractPasswords(masterKey []byte, path string) ([]types.LoginEntry, error)
6868
URL: url,
6969
Username: string(user),
7070
Password: string(pwd),
71-
CreatedAt: timestamp(v.Get("timeCreated").Int() / 1000),
71+
CreatedAt: firefoxMillis(v.Get("timeCreated").Int()),
7272
})
7373
}
7474
if decryptFails > 0 {

browser/firefox/firefox.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,11 +288,38 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir stri
288288
return resolved
289289
}
290290

291-
// timestamp converts a Unix epoch timestamp (seconds) to a time.Time.
292-
func timestamp(stamp int64) time.Time {
293-
s := time.Unix(stamp, 0)
294-
if s.Local().Year() > 9999 {
295-
return time.Date(9999, 12, 13, 23, 59, 59, 0, time.Local)
291+
// Firefox uses three timestamp units. Helpers emit UTC and return the zero
292+
// time.Time for non-positive or out-of-JSON-range input.
293+
//
294+
// - firefoxMicros: PRTime (μs since Unix epoch) — moz_* tables.
295+
// - firefoxMillis: Date.now() (ms) — logins.json, download endTime.
296+
// - firefoxSeconds: seconds — moz_cookies.expiry only.
297+
func firefoxMicros(us int64) time.Time {
298+
if us <= 0 {
299+
return time.Time{}
296300
}
297-
return s
301+
return clampJSON(time.UnixMicro(us).UTC())
302+
}
303+
304+
func firefoxMillis(ms int64) time.Time {
305+
if ms <= 0 {
306+
return time.Time{}
307+
}
308+
return clampJSON(time.UnixMilli(ms).UTC())
309+
}
310+
311+
func firefoxSeconds(s int64) time.Time {
312+
if s <= 0 {
313+
return time.Time{}
314+
}
315+
return clampJSON(time.Unix(s, 0).UTC())
316+
}
317+
318+
// clampJSON maps years outside time.Time.MarshalJSON's [1, 9999] window
319+
// to the zero time, so JSON export can't crash on sentinel inputs.
320+
func clampJSON(t time.Time) time.Time {
321+
if t.Year() < 1 || t.Year() > 9999 {
322+
return time.Time{}
323+
}
324+
return t
298325
}

browser/firefox/firefox_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"path/filepath"
66
"testing"
7+
"time"
78

89
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
@@ -315,3 +316,79 @@ func TestExtractCategory(t *testing.T) {
315316
assert.Empty(t, data.SessionStorage)
316317
})
317318
}
319+
320+
// Anchor: 2024-01-15T10:30:00Z.
321+
const anchorUnixSeconds = int64(1705314600)
322+
323+
func TestFirefoxMicros_AnchorDate(t *testing.T) {
324+
got := firefoxMicros(anchorUnixSeconds * 1_000_000)
325+
want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
326+
assert.Equal(t, want, got)
327+
}
328+
329+
func TestFirefoxMicros_PrecisionPreserved(t *testing.T) {
330+
got := firefoxMicros(anchorUnixSeconds*1_000_000 + 123456)
331+
assert.Equal(t, 123456*int64(time.Microsecond), int64(got.Nanosecond()))
332+
}
333+
334+
func TestFirefoxMillis_AnchorDate(t *testing.T) {
335+
got := firefoxMillis(anchorUnixSeconds * 1_000)
336+
want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
337+
assert.Equal(t, want, got)
338+
}
339+
340+
func TestFirefoxMillis_PrecisionPreserved(t *testing.T) {
341+
got := firefoxMillis(anchorUnixSeconds*1_000 + 789)
342+
assert.Equal(t, 789*int64(time.Millisecond), int64(got.Nanosecond()))
343+
}
344+
345+
func TestFirefoxSeconds_AnchorDate(t *testing.T) {
346+
got := firefoxSeconds(anchorUnixSeconds)
347+
want := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
348+
assert.Equal(t, want, got)
349+
}
350+
351+
func TestFirefoxHelpers_ZeroReturnsZeroTime(t *testing.T) {
352+
assert.True(t, firefoxMicros(0).IsZero(), "micros")
353+
assert.True(t, firefoxMillis(0).IsZero(), "millis")
354+
assert.True(t, firefoxSeconds(0).IsZero(), "seconds")
355+
}
356+
357+
func TestFirefoxHelpers_NegativeReturnsZeroTime(t *testing.T) {
358+
assert.True(t, firefoxMicros(-1).IsZero(), "micros")
359+
assert.True(t, firefoxMillis(-1).IsZero(), "millis")
360+
assert.True(t, firefoxSeconds(-1).IsZero(), "seconds")
361+
}
362+
363+
func TestFirefoxHelpers_AlwaysUTC(t *testing.T) {
364+
// assert.Same: pointer equality reliably catches any helper that
365+
// leaks time.Local, independent of the runner's configured TZ.
366+
assert.Same(t, time.UTC, firefoxMicros(anchorUnixSeconds*1_000_000).Location())
367+
assert.Same(t, time.UTC, firefoxMillis(anchorUnixSeconds*1_000).Location())
368+
assert.Same(t, time.UTC, firefoxSeconds(anchorUnixSeconds).Location())
369+
}
370+
371+
func TestFirefoxHelpers_SameMomentAcrossUnits(t *testing.T) {
372+
us := firefoxMicros(anchorUnixSeconds * 1_000_000)
373+
ms := firefoxMillis(anchorUnixSeconds * 1_000)
374+
s := firefoxSeconds(anchorUnixSeconds)
375+
assert.True(t, us.Equal(ms))
376+
assert.True(t, ms.Equal(s))
377+
}
378+
379+
func TestFirefoxHelpers_OutOfJSONRangeReturnsZero(t *testing.T) {
380+
for _, tc := range []struct {
381+
name string
382+
got time.Time
383+
}{
384+
{"seconds", firefoxSeconds(1 << 50)},
385+
{"millis", firefoxMillis(1 << 60)},
386+
{"micros", firefoxMicros(1 << 62)},
387+
} {
388+
t.Run(tc.name, func(t *testing.T) {
389+
b, err := tc.got.MarshalJSON()
390+
require.NoError(t, err)
391+
assert.JSONEq(t, `"0001-01-01T00:00:00Z"`, string(b))
392+
})
393+
}
394+
}

browser/safari/extract_cookie.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) {
2020
for _, page := range pages {
2121
for _, c := range page.Cookies {
2222
hasExpire := !c.Expires.IsZero()
23+
// binarycookies returns time.Time in Local; normalize to UTC
24+
// so exported JSON matches Chromium/Firefox cookie output.
2325
cookies = append(cookies, types.CookieEntry{
2426
Host: string(c.Domain),
2527
Path: string(c.Path),
@@ -29,8 +31,8 @@ func extractCookies(path string) ([]types.CookieEntry, error) {
2931
IsHTTPOnly: c.HTTPOnly,
3032
HasExpire: hasExpire,
3133
IsPersistent: hasExpire,
32-
ExpireAt: c.Expires,
33-
CreatedAt: c.Creation,
34+
ExpireAt: c.Expires.UTC(),
35+
CreatedAt: c.Creation.UTC(),
3436
})
3537
}
3638
}

0 commit comments

Comments
 (0)