Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
}
60 changes: 60 additions & 0 deletions browser/chromium/chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -720,3 +721,62 @@
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)

Check failure on line 780 in browser/chromium/chromium_test.go

View workflow job for this annotation

GitHub Actions / Lint (ubuntu-latest)

require-error: for error assertions use require (testifylint)
assert.Equal(t, `"0001-01-01T00:00:00Z"`, string(jsonBytes))

Check failure on line 781 in browser/chromium/chromium_test.go

View workflow job for this annotation

GitHub Actions / Lint (ubuntu-latest)

encoded-compare: use assert.JSONEq (testifylint)
}
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 [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
}
88 changes: 88 additions & 0 deletions browser/firefox/firefox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -315,3 +316,90 @@
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)

Check failure on line 401 in browser/firefox/firefox_test.go

View workflow job for this annotation

GitHub Actions / Lint (ubuntu-latest)

require-error: for error assertions use require (testifylint)
assert.Equal(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
16 changes: 14 additions & 2 deletions browser/safari/safari.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading