Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions browser/chromium/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions browser/chromium/chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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))
}
2 changes: 1 addition & 1 deletion browser/firefox/extract_bookmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions browser/firefox/extract_cookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions browser/firefox/extract_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion browser/firefox/extract_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion browser/firefox/extract_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 33 additions & 6 deletions browser/firefox/firefox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
77 changes: 77 additions & 0 deletions browser/firefox/firefox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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))
})
}
}
6 changes: 4 additions & 2 deletions browser/safari/extract_cookie.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(),
})
}
}
Expand Down
7 changes: 4 additions & 3 deletions browser/safari/extract_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion browser/safari/extract_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
}

Expand Down
18 changes: 16 additions & 2 deletions browser/safari/safari.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 30 additions & 0 deletions browser/safari/safari_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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())
}
Loading