feat(safari): multi-profile support (#581)

* feat(safari): multi-profile support
This commit is contained in:
Roger
2026-04-21 15:50:36 +08:00
committed by GitHub
parent 7b9a973c9c
commit d75738b90f
8 changed files with 676 additions and 103 deletions
+24 -10
View File
@@ -9,20 +9,23 @@ import (
"github.com/moond4rk/hackbrowserdata/types"
)
// safariDownloads mirrors the plist structure of Safari's Downloads.plist.
type safariDownloads struct {
DownloadHistory []safariDownloadEntry `plist:"DownloadHistory"`
}
type safariDownloadEntry struct {
URL string `plist:"DownloadEntryURL"`
Path string `plist:"DownloadEntryPath"`
TotalBytes float64 `plist:"DownloadEntryProgressTotalToLoad"`
RemoveWhenDone bool `plist:"DownloadEntryRemoveWhenDoneKey"`
DownloadIdentifier string `plist:"DownloadEntryIdentifier"`
URL string `plist:"DownloadEntryURL"`
Path string `plist:"DownloadEntryPath"`
TotalBytes int64 `plist:"DownloadEntryProgressTotalToLoad"`
ProfileUUID string `plist:"DownloadEntryProfileUUIDStringKey"`
RemoveWhenDone bool `plist:"DownloadEntryRemoveWhenDoneKey"`
DownloadIdentifier string `plist:"DownloadEntryIdentifier"`
}
func extractDownloads(path string) ([]types.DownloadEntry, error) {
// extractDownloads reads Downloads.plist (shared across Safari profiles) and returns only the entries
// owned by ownerUUID — either "DefaultProfile" or a named profile's uppercase UUID. Entries written by
// older Safari (no ProfileUUID field) are attributed to the default profile.
func extractDownloads(path, ownerUUID string) ([]types.DownloadEntry, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open downloads: %w", err)
@@ -36,19 +39,30 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) {
var downloads []types.DownloadEntry
for _, d := range dl.DownloadHistory {
if !ownsDownload(d.ProfileUUID, ownerUUID) {
continue
}
downloads = append(downloads, types.DownloadEntry{
URL: d.URL,
TargetPath: d.Path,
TotalBytes: int64(d.TotalBytes),
TotalBytes: d.TotalBytes,
})
}
return downloads, nil
}
func countDownloads(path string) (int, error) {
downloads, err := extractDownloads(path)
func countDownloads(path, ownerUUID string) (int, error) {
downloads, err := extractDownloads(path, ownerUUID)
if err != nil {
return 0, err
}
return len(downloads), nil
}
// ownsDownload treats empty ProfileUUID as DefaultProfile for backward compat with pre-profile Safari.
func ownsDownload(entryUUID, ownerUUID string) bool {
if entryUUID == "" {
entryUUID = defaultProfileSentinel
}
return entryUUID == ownerUUID
}
+30 -22
View File
@@ -20,46 +20,54 @@ func buildTestDownloadsPlist(t *testing.T, dl safariDownloads) string {
return path
}
func TestExtractDownloads(t *testing.T) {
func TestExtractDownloads_DefaultProfileOnly(t *testing.T) {
// Mixed-owner plist: only entries tagged with DefaultProfile (or untagged, for
// pre-profile Safari) should surface for the default profile.
const namedUUID = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
dl := safariDownloads{
DownloadHistory: []safariDownloadEntry{
{
URL: "https://example.com/file.zip",
Path: "/Users/test/Downloads/file.zip",
TotalBytes: 1024000,
},
{
URL: "https://go.dev/dl/go1.20.tar.gz",
Path: "/Users/test/Downloads/go1.20.tar.gz",
TotalBytes: 98765432,
},
{URL: "https://a.com/a.zip", Path: "/tmp/a.zip", TotalBytes: 1024000, ProfileUUID: defaultProfileSentinel},
{URL: "https://b.com/b.zip", Path: "/tmp/b.zip", TotalBytes: 98765432, ProfileUUID: namedUUID},
{URL: "https://c.com/legacy.zip", Path: "/tmp/legacy.zip", TotalBytes: 500, ProfileUUID: ""}, // pre-profile Safari
},
}
path := buildTestDownloadsPlist(t, dl)
downloads, err := extractDownloads(path)
downloads, err := extractDownloads(path, defaultProfileSentinel)
require.NoError(t, err)
require.Len(t, downloads, 2)
assert.Equal(t, "https://a.com/a.zip", downloads[0].URL)
assert.Equal(t, "https://c.com/legacy.zip", downloads[1].URL)
}
assert.Equal(t, "https://example.com/file.zip", downloads[0].URL)
assert.Equal(t, "/Users/test/Downloads/file.zip", downloads[0].TargetPath)
assert.Equal(t, int64(1024000), downloads[0].TotalBytes)
func TestExtractDownloads_NamedProfileOnly(t *testing.T) {
const namedUUID = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
dl := safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://a.com/a.zip", Path: "/tmp/a.zip", TotalBytes: 100, ProfileUUID: defaultProfileSentinel},
{URL: "https://b.com/b.zip", Path: "/tmp/b.zip", TotalBytes: 200, ProfileUUID: namedUUID},
},
}
assert.Equal(t, "https://go.dev/dl/go1.20.tar.gz", downloads[1].URL)
assert.Equal(t, int64(98765432), downloads[1].TotalBytes)
path := buildTestDownloadsPlist(t, dl)
downloads, err := extractDownloads(path, namedUUID)
require.NoError(t, err)
require.Len(t, downloads, 1)
assert.Equal(t, "https://b.com/b.zip", downloads[0].URL)
assert.Equal(t, int64(200), downloads[0].TotalBytes)
}
func TestCountDownloads(t *testing.T) {
dl := safariDownloads{
DownloadHistory: []safariDownloadEntry{
{URL: "https://a.com/1.zip", Path: "/tmp/1.zip", TotalBytes: 100},
{URL: "https://b.com/2.zip", Path: "/tmp/2.zip", TotalBytes: 200},
{URL: "https://c.com/3.zip", Path: "/tmp/3.zip", TotalBytes: 300},
{URL: "https://a.com/1.zip", Path: "/tmp/1.zip", TotalBytes: 100, ProfileUUID: defaultProfileSentinel},
{URL: "https://b.com/2.zip", Path: "/tmp/2.zip", TotalBytes: 200, ProfileUUID: defaultProfileSentinel},
{URL: "https://c.com/3.zip", Path: "/tmp/3.zip", TotalBytes: 300, ProfileUUID: defaultProfileSentinel},
},
}
path := buildTestDownloadsPlist(t, dl)
count, err := countDownloads(path)
count, err := countDownloads(path, defaultProfileSentinel)
require.NoError(t, err)
assert.Equal(t, 3, count)
}
@@ -68,7 +76,7 @@ func TestExtractDownloads_Empty(t *testing.T) {
dl := safariDownloads{}
path := buildTestDownloadsPlist(t, dl)
downloads, err := extractDownloads(path)
downloads, err := extractDownloads(path, defaultProfileSentinel)
require.NoError(t, err)
assert.Empty(t, downloads)
}
+178
View File
@@ -0,0 +1,178 @@
package safari
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
_ "modernc.org/sqlite"
"github.com/moond4rk/hackbrowserdata/log"
)
// profileContext tracks the uppercase (Safari/Profiles/<UUID>) and lowercase
// (WebKit/WebsiteDataStore/<uuid>) UUID forms a named profile needs. Both empty ⇒ default profile.
type profileContext struct {
name string
uuidUpper string
uuidLower string
legacyHome string // ~/Library/Safari
container string // ~/Library/Containers/com.apple.Safari/Data/Library
}
func (p profileContext) isDefault() bool { return p.uuidUpper == "" }
// downloadOwnerUUID is the value Safari writes into DownloadEntryProfileUUIDStringKey
// for downloads that belong to this profile. The default profile uses the sentinel
// "DefaultProfile"; named profiles use their uppercase UUID.
func (p profileContext) downloadOwnerUUID() string {
if p.isDefault() {
return defaultProfileSentinel
}
return p.uuidUpper
}
// SafariTabs.db lists profiles in bookmarks rows with subtype=2. external_uuid "DefaultProfile"
// is the sentinel for the implicit default, which has no per-UUID directory.
const (
safariTabsDBRelPath = "Safari/SafariTabs.db"
safariProfileSubtype = 2
defaultProfileSentinel = "DefaultProfile"
)
// Path-unsafe bytes for filenames/CSV values; Unicode letters (CJK etc.) survive.
var unsafeNameChars = regexp.MustCompile(`[/\\:*?"<>|\x00-\x1f]+`)
// Canonical 8-4-4-4-12 hex UUID — format check only, no semantic parse.
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`)
// discoverSafariProfiles always lists the default first, then named profiles from SafariTabs.db
// (authoritative) with a ReadDir fallback only if the DB itself is unreadable.
func discoverSafariProfiles(legacyHome string) []profileContext {
container := deriveContainerRoot(legacyHome)
profiles := []profileContext{{
name: "default",
legacyHome: legacyHome,
container: container,
}}
named, err := readNamedProfilesFromDB(container)
if err != nil {
// Empty DB (nil, nil) is authoritative; fall back only when DB itself is unreadable.
named = readNamedProfilesFromDir(container)
}
for _, p := range named {
p.legacyHome = legacyHome
p.container = container
profiles = append(profiles, p)
}
disambiguateNames(profiles)
return profiles
}
func deriveContainerRoot(legacyHome string) string {
return filepath.Join(filepath.Dir(legacyHome), "Containers", "com.apple.Safari", "Data", "Library")
}
// readNamedProfilesFromDB returns (nil, err) when the DB is missing/unreadable so the caller can
// try the ReadDir fallback; (slice, nil) — possibly empty — is authoritative.
func readNamedProfilesFromDB(container string) ([]profileContext, error) {
// Read-only + immutable so we don't disturb Safari's live WAL.
dsn := "file:" + filepath.Join(container, safariTabsDBRelPath) + "?mode=ro&immutable=1"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open SafariTabs.db: %w", err)
}
defer db.Close()
// Ping forces connection; sql.Open is lazy and won't detect a missing file.
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping SafariTabs.db: %w", err)
}
rows, err := db.Query(
`SELECT external_uuid, title FROM bookmarks WHERE subtype = ? AND external_uuid != ?`,
safariProfileSubtype, defaultProfileSentinel,
)
if err != nil {
return nil, fmt.Errorf("query SafariTabs.db: %w", err)
}
defer rows.Close()
var out []profileContext
for rows.Next() {
var externalUUID, title sql.NullString
if err := rows.Scan(&externalUUID, &title); err != nil {
log.Debugf("safari profiles: scan row: %v", err)
continue
}
if !isCanonicalUUID(externalUUID.String) {
continue
}
out = append(out, newNamedProfile(externalUUID.String, title.String))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate SafariTabs.db rows: %w", err)
}
return out, nil
}
// readNamedProfilesFromDir is the fallback for missing SafariTabs.db. Names are synthesized from UUIDs.
func readNamedProfilesFromDir(container string) []profileContext {
entries, err := os.ReadDir(filepath.Join(container, "Safari", "Profiles"))
if err != nil {
return nil
}
var out []profileContext
for _, e := range entries {
if !e.IsDir() || !isCanonicalUUID(e.Name()) {
continue
}
out = append(out, newNamedProfile(e.Name(), ""))
}
return out
}
func newNamedProfile(upperUUID, title string) profileContext {
return profileContext{
name: resolveProfileName(title, upperUUID),
uuidUpper: upperUUID,
uuidLower: strings.ToLower(upperUUID),
}
}
func isCanonicalUUID(s string) bool { return uuidPattern.MatchString(s) }
// resolveProfileName prefers the SafariTabs.db title, falling back to "profile-<uuid[:8]>".
func resolveProfileName(title, upperUUID string) string {
if name := sanitizeProfileName(title); name != "" {
return name
}
return "profile-" + strings.ToLower(upperUUID[:8])
}
func sanitizeProfileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
return unsafeNameChars.ReplaceAllString(name, "_")
}
// disambiguateNames appends "-2", "-3", … to duplicate names, in place.
func disambiguateNames(profiles []profileContext) {
occurrences := make(map[string]int, len(profiles))
for i := range profiles {
original := profiles[i].name
if prior := occurrences[original]; prior > 0 {
profiles[i].name = fmt.Sprintf("%s-%d", original, prior+1)
}
occurrences[original]++
}
}
+259
View File
@@ -0,0 +1,259 @@
package safari
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/moond4rk/hackbrowserdata/types"
)
// containerPaths returns (legacyHome, container) for a fake ~/Library tree
// anchored at root. Call sites use this to mirror the production layout where
// legacyHome sits next to Containers/.
func containerPaths(root string) (string, string) {
legacyHome := filepath.Join(root, "Safari")
container := filepath.Join(root, "Containers", "com.apple.Safari", "Data", "Library")
return legacyHome, container
}
func TestDiscoverSafariProfiles_DefaultOnly(t *testing.T) {
library := t.TempDir()
legacyHome, _ := containerPaths(library)
mkFile(t, legacyHome, "History.db")
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 1)
assert.Equal(t, "default", got[0].name)
assert.Empty(t, got[0].uuidUpper)
assert.Empty(t, got[0].uuidLower)
}
func TestDiscoverSafariProfiles_WithNamedProfile(t *testing.T) {
const uuid = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
mkFile(t, container, "Safari", "Profiles", uuid, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: "DefaultProfile", title: ""},
{uuid: uuid, title: "work"},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2)
assert.Equal(t, "default", got[0].name)
assert.Equal(t, "work", got[1].name)
assert.Equal(t, uuid, got[1].uuidUpper)
assert.Equal(t, strings.ToLower(uuid), got[1].uuidLower)
}
func TestDiscoverSafariProfiles_EmptyTitleFallbackToUUID(t *testing.T) {
const uuid = "ABCDEF01-2345-6789-ABCD-EF0123456789"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: uuid, title: ""},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2)
assert.Equal(t, "profile-abcdef01", got[1].name)
}
func TestDiscoverSafariProfiles_OrphanUUIDWithoutDBEntry(t *testing.T) {
// Profile directory with a History.db exists on disk but is absent from
// SafariTabs.db. When the DB is readable and doesn't mention it, we trust
// the DB — the orphan stays hidden because production filters profiles
// with no resolvable data in NewBrowsers anyway. Here we assert discovery
// returns only what the DB declares.
const dbUUID = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
const orphanUUID = "11111111-2222-3333-4444-555555555555"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
mkFile(t, container, "Safari", "Profiles", dbUUID, "History.db")
mkFile(t, container, "Safari", "Profiles", orphanUUID, "AppExtensions", "Extensions.plist")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: dbUUID, title: "declared"},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2)
assert.Equal(t, "default", got[0].name)
assert.Equal(t, "declared", got[1].name)
}
func TestDiscoverSafariProfiles_EmptyDBIsAuthoritative(t *testing.T) {
// SafariTabs.db exists and is readable but contains no named-profile rows.
// A stray Profiles/<UUID>/ directory on disk must NOT sneak in via the
// ReadDir fallback — the DB is the authoritative source of truth.
const strayUUID = "99999999-AAAA-BBBB-CCCC-DDDDDDDDDDDD"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
mkFile(t, container, "Safari", "Profiles", strayUUID, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), nil) // zero rows
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 1)
assert.Equal(t, "default", got[0].name)
}
func TestDiscoverSafariProfiles_MissingDBFallsBackToReadDir(t *testing.T) {
// SafariTabs.db absent → enumerate Safari/Profiles/ and synthesize names.
const uuid = "11111111-2222-3333-4444-555555555555"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
mkFile(t, container, "Safari", "Profiles", uuid, "History.db")
// Deliberately also drop a non-UUID directory that must be ignored.
require.NoError(t, os.MkdirAll(filepath.Join(container, "Safari", "Profiles", "Bogus"), 0o755))
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2)
assert.Equal(t, "default", got[0].name)
assert.Equal(t, "profile-11111111", got[1].name)
assert.Equal(t, uuid, got[1].uuidUpper)
}
func TestDiscoverSafariProfiles_DuplicateTitlesDisambiguate(t *testing.T) {
const uuidA = "AAAAAAAA-0000-0000-0000-000000000001"
const uuidB = "BBBBBBBB-0000-0000-0000-000000000002"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: uuidA, title: "team"},
{uuid: uuidB, title: "team"},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 3)
// Order mirrors DB insertion order; the second "team" becomes "team-2".
assert.Equal(t, "default", got[0].name)
assert.Equal(t, "team", got[1].name)
assert.Equal(t, "team-2", got[2].name)
}
func TestDiscoverSafariProfiles_UUIDCaseNormalisation(t *testing.T) {
// SafariTabs.db always stores UUIDs uppercase (verified on real Mac).
// WebKit/WebsiteDataStore uses lowercase — we must carry both.
const uuid = "FEDCBA98-7654-3210-FEDC-BA9876543210"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: uuid, title: "alpha"},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2)
assert.Equal(t, uuid, got[1].uuidUpper)
assert.Equal(t, strings.ToLower(uuid), got[1].uuidLower)
}
func TestDiscoverSafariProfiles_DefaultProfileSentinelIgnored(t *testing.T) {
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: defaultProfileSentinel, title: ""},
})
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 1)
assert.Equal(t, "default", got[0].name)
}
func TestDiscoverSafariProfiles_EmptyProfileDirectoryFiltersOutInNewBrowsers(t *testing.T) {
// Matches the real 4E2D8DD0 orphan on the author's Mac: a profile dir
// listed in neither SafariTabs.db nor containing any extractable data.
// Discovery without the DB surfaces it; NewBrowsers then drops it when
// resolveSourcePaths yields zero matches.
const uuid = "4E2D8DD0-A7D2-4684-939A-898B7675C700"
library := t.TempDir()
legacyHome, container := containerPaths(library)
mkFile(t, legacyHome, "History.db")
mkFile(t, container, "Safari", "Profiles", uuid, "AppExtensions", "Extensions.plist")
got := discoverSafariProfiles(legacyHome)
require.Len(t, got, 2) // discovery includes it …
paths := resolveSourcePaths(buildSources(got[1]))
assert.Empty(t, paths) // … but no supported data resolves for it.
}
func TestResolveProfileName(t *testing.T) {
tests := []struct {
title string
uuid string
want string
}{
{"work", "5604E6F5-02ED-4E40-8249-63DE7BC986C8", "work"},
{" spaced ", "5604E6F5-02ED-4E40-8249-63DE7BC986C8", "spaced"},
{"", "5604E6F5-02ED-4E40-8249-63DE7BC986C8", "profile-5604e6f5"},
{"with/slash", "AAAAAAAA-0000-0000-0000-000000000000", "with_slash"},
{"中文", "AAAAAAAA-0000-0000-0000-000000000000", "中文"},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
assert.Equal(t, tt.want, resolveProfileName(tt.title, tt.uuid))
})
}
}
func TestBuildSources_DefaultProfile(t *testing.T) {
library := t.TempDir()
legacyHome, container := containerPaths(library)
p := profileContext{legacyHome: legacyHome, container: container}
sources := buildSources(p)
assert.Equal(t, filepath.Join(legacyHome, "History.db"), sources[types.History][0].abs)
assert.Equal(t, filepath.Join(legacyHome, "Bookmarks.plist"), sources[types.Bookmark][0].abs)
assert.Equal(t, filepath.Join(legacyHome, "Downloads.plist"), sources[types.Download][0].abs)
require.Len(t, sources[types.Cookie], 2)
assert.Equal(t, filepath.Join(container, "Cookies", "Cookies.binarycookies"), sources[types.Cookie][0].abs)
assert.Equal(t, filepath.Join(library, "Cookies", "Cookies.binarycookies"), sources[types.Cookie][1].abs)
}
func TestBuildSources_NamedProfile(t *testing.T) {
const uuid = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
library := t.TempDir()
legacyHome, container := containerPaths(library)
p := profileContext{
name: "work",
uuidUpper: uuid,
uuidLower: strings.ToLower(uuid),
legacyHome: legacyHome,
container: container,
}
sources := buildSources(p)
assert.Equal(t,
filepath.Join(container, "Safari", "Profiles", uuid, "History.db"),
sources[types.History][0].abs)
assert.Equal(t,
filepath.Join(container, "WebKit", "WebsiteDataStore", strings.ToLower(uuid), "Cookies", "Cookies.binarycookies"),
sources[types.Cookie][0].abs)
// Download points at the shared plist — filtering by DownloadEntryProfileUUIDStringKey
// happens inside extractDownloads, not at the path layer.
assert.Equal(t, filepath.Join(legacyHome, "Downloads.plist"), sources[types.Download][0].abs)
// Bookmark is still shared with no per-entry profile tag, so it's attributed to default only.
assert.NotContains(t, sources, types.Bookmark)
}
+46 -49
View File
@@ -10,45 +10,49 @@ import (
"github.com/moond4rk/hackbrowserdata/types"
)
// Browser represents Safari browser data ready for extraction.
// Safari has a single flat data directory (no profile subdirectories)
// and stores most data unencrypted (passwords live in macOS Keychain).
// Browser is one Safari profile's data ready for extraction. Passwords come from the shared macOS
// Keychain; everything else reads from the profile's directories.
type Browser struct {
cfg types.BrowserConfig
dataDir string // absolute path to ~/Library/Safari
keychainPassword string // macOS login password for Keychain unlock
sources map[types.Category][]sourcePath // Category → candidate paths
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
profile profileContext
keychainPassword string
sourcePaths map[types.Category]resolvedPath
}
// SetKeychainPassword sets the macOS login password used to unlock
// the Keychain for Safari password extraction.
func (b *Browser) SetKeychainPassword(password string) {
b.keychainPassword = password
}
func (b *Browser) SetKeychainPassword(password string) { b.keychainPassword = password }
// NewBrowsers checks whether Safari data exists at cfg.UserDataDir and returns
// a single Browser if any known source files are found. Unlike Chromium/Firefox,
// Safari has no profile directories — the data directory is used directly.
// NewBrowsers returns one Browser per Safari profile with resolvable data. Named profiles are
// enumerated from SafariTabs.db.
func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
sourcePaths := resolveSourcePaths(safariSources, cfg.UserDataDir)
if len(sourcePaths) == 0 {
return nil, nil
var browsers []*Browser
for _, p := range discoverSafariProfiles(cfg.UserDataDir) {
paths := resolveProfilePaths(p)
if len(paths) == 0 {
continue
}
browsers = append(browsers, &Browser{
cfg: cfg,
profile: p,
sourcePaths: paths,
})
}
return []*Browser{{
cfg: cfg,
dataDir: cfg.UserDataDir,
sources: safariSources,
sourcePaths: sourcePaths,
}}, nil
return browsers, nil
}
func resolveProfilePaths(p profileContext) map[types.Category]resolvedPath {
return resolveSourcePaths(buildSources(p))
}
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.dataDir }
func (b *Browser) ProfileName() string { return "default" }
func (b *Browser) ProfileName() string { return b.profile.name }
func (b *Browser) ProfileDir() string {
if b.profile.isDefault() {
return b.profile.legacyHome
}
return filepath.Join(b.profile.container, "Safari", "Profiles", b.profile.uuidUpper)
}
// Extract copies browser files to a temp directory and extracts data
// for the requested categories.
func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, error) {
session, err := filemanager.NewSession()
if err != nil {
@@ -60,9 +64,11 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
data := &types.BrowserData{}
for _, cat := range categories {
// Password is stored in macOS Keychain, not in a file.
// Keychain is user-scope, not per-profile — attribute only to default to avoid duplicates.
if cat == types.Password {
b.extractCategory(data, cat, "")
if b.profile.isDefault() {
b.extractCategory(data, cat, "")
}
continue
}
path, ok := tempPaths[cat]
@@ -74,8 +80,6 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
return data, nil
}
// CountEntries copies browser files to a temp directory and counts entries
// per category without full extraction.
func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]int, error) {
session, err := filemanager.NewSession()
if err != nil {
@@ -88,7 +92,9 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]
counts := make(map[types.Category]int)
for _, cat := range categories {
if cat == types.Password {
counts[cat] = b.countCategory(cat, "")
if b.profile.isDefault() {
counts[cat] = b.countCategory(cat, "")
}
continue
}
path, ok := tempPaths[cat]
@@ -100,7 +106,6 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]
return counts, nil
}
// acquireFiles copies source files to the session temp directory.
func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.Category) map[types.Category]string {
tempPaths := make(map[types.Category]string)
for _, cat := range categories {
@@ -118,7 +123,6 @@ func (b *Browser) acquireFiles(session *filemanager.Session, categories []types.
return tempPaths
}
// extractCategory calls the appropriate extract function for a category.
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, path string) {
var err error
switch cat {
@@ -131,7 +135,7 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p
case types.Bookmark:
data.Bookmarks, err = extractBookmarks(path)
case types.Download:
data.Downloads, err = extractDownloads(path)
data.Downloads, err = extractDownloads(path, b.profile.downloadOwnerUUID())
default:
return
}
@@ -140,7 +144,6 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p
}
}
// countCategory calls the appropriate count function for a category.
func (b *Browser) countCategory(cat types.Category, path string) int {
var count int
var err error
@@ -154,7 +157,7 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
case types.Bookmark:
count, err = countBookmarks(path)
case types.Download:
count, err = countDownloads(path)
count, err = countDownloads(path, b.profile.downloadOwnerUUID())
default:
// Unsupported categories silently return 0.
}
@@ -164,25 +167,22 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
return count
}
// resolvedPath holds the absolute path and type for a discovered source.
type resolvedPath struct {
absPath string
isDir bool
}
// resolveSourcePaths checks which sources actually exist in dataDir.
// Candidates are tried in priority order; the first existing path wins.
func resolveSourcePaths(sources map[types.Category][]sourcePath, dataDir string) map[types.Category]resolvedPath {
// resolveSourcePaths returns only paths that exist; first matching candidate wins per category.
func resolveSourcePaths(sources map[types.Category][]sourcePath) map[types.Category]resolvedPath {
resolved := make(map[types.Category]resolvedPath)
for cat, candidates := range sources {
for _, sp := range candidates {
abs := filepath.Join(dataDir, sp.rel)
info, err := os.Stat(abs)
info, err := os.Stat(sp.abs)
if err != nil {
continue
}
if sp.isDir == info.IsDir() {
resolved[cat] = resolvedPath{abs, sp.isDir}
resolved[cat] = resolvedPath{sp.abs, sp.isDir}
break
}
}
@@ -190,12 +190,9 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath, dataDir string)
return resolved
}
// coreDataEpochOffset is the number of seconds between the Unix epoch
// (1970-01-01) and the Core Data epoch (2001-01-01).
// Safari's History.db uses the Core Data epoch (2001-01-01) instead of Unix epoch.
const coreDataEpochOffset = 978307200
// coredataTimestamp converts a Core Data timestamp (seconds since 2001-01-01)
// to a time.Time. Safari's History.db uses this epoch for visit_time.
func coredataTimestamp(seconds float64) time.Time {
return time.Unix(int64(seconds)+coreDataEpochOffset, 0)
}
+56 -4
View File
@@ -19,7 +19,7 @@ func mkFile(t *testing.T, parts ...string) {
}
// ---------------------------------------------------------------------------
// NewBrowsers
// NewBrowsers — backward-compat (single flat profile)
// ---------------------------------------------------------------------------
func TestNewBrowsers(t *testing.T) {
@@ -72,6 +72,56 @@ func TestNewBrowsers(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// NewBrowsers — multi-profile (macOS 14+ named profiles)
// ---------------------------------------------------------------------------
func TestNewBrowsers_MultiProfile(t *testing.T) {
const uuid = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
// Build a pretend ~/Library that mirrors a macOS 14+ layout.
library := t.TempDir()
legacyHome := filepath.Join(library, "Safari")
container := filepath.Join(library, "Containers", "com.apple.Safari", "Data", "Library")
// Default profile data in legacyHome.
mkFile(t, legacyHome, "History.db")
mkFile(t, legacyHome, "Bookmarks.plist")
// Named profile data under the container.
mkFile(t, container, "Safari", "Profiles", uuid, "History.db")
// SafariTabs.db registering the named profile with a human-readable title.
writeSafariTabsDB(t, filepath.Join(container, safariTabsDBRelPath), []tabRow{
{uuid: "DefaultProfile", title: ""},
{uuid: uuid, title: "work"},
})
cfg := types.BrowserConfig{Name: "Safari", Kind: types.Safari, UserDataDir: legacyHome}
browsers, err := NewBrowsers(cfg)
require.NoError(t, err)
require.Len(t, browsers, 2)
names := []string{browsers[0].ProfileName(), browsers[1].ProfileName()}
assert.Contains(t, names, "default")
assert.Contains(t, names, "work")
for _, b := range browsers {
switch b.ProfileName() {
case "default":
assert.Equal(t, legacyHome, b.ProfileDir())
assert.Contains(t, b.sourcePaths, types.History)
assert.Equal(t, filepath.Join(legacyHome, "History.db"), b.sourcePaths[types.History].absPath)
case "work":
assert.Equal(t, filepath.Join(container, "Safari", "Profiles", uuid), b.ProfileDir())
assert.Contains(t, b.sourcePaths, types.History)
assert.Equal(t,
filepath.Join(container, "Safari", "Profiles", uuid, "History.db"),
b.sourcePaths[types.History].absPath)
}
}
}
// ---------------------------------------------------------------------------
// resolveSourcePaths
// ---------------------------------------------------------------------------
@@ -80,15 +130,17 @@ func TestResolveSourcePaths(t *testing.T) {
dir := t.TempDir()
mkFile(t, dir, "History.db")
resolved := resolveSourcePaths(safariSources, dir)
sources := buildSources(profileContext{legacyHome: dir, container: deriveContainerRoot(dir)})
resolved := resolveSourcePaths(sources)
assert.Contains(t, resolved, types.History)
assert.Equal(t, filepath.Join(dir, "History.db"), resolved[types.History].absPath)
assert.False(t, resolved[types.History].isDir)
}
func TestResolveSourcePaths_Empty(t *testing.T) {
resolved := resolveSourcePaths(safariSources, t.TempDir())
assert.Empty(t, resolved)
dir := t.TempDir()
sources := buildSources(profileContext{legacyHome: dir, container: deriveContainerRoot(dir)})
assert.Empty(t, resolveSourcePaths(sources))
}
// ---------------------------------------------------------------------------
+47 -18
View File
@@ -6,26 +6,55 @@ import (
"github.com/moond4rk/hackbrowserdata/types"
)
// sourcePath describes a single candidate location for browser data,
// relative to the Safari data directory.
type sourcePath struct {
rel string // relative path from dataDir
isDir bool // true for directory targets
abs string
isDir bool
}
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel)} }
func file(abs string) sourcePath { return sourcePath{abs: abs} }
// safariSources defines the Safari file layout.
// Each category maps to one or more candidate paths tried in priority order;
// the first existing path wins.
var safariSources = map[types.Category][]sourcePath{
types.History: {file("History.db")},
types.Bookmark: {file("Bookmarks.plist")},
types.Download: {file("Downloads.plist")},
types.Cookie: {
// macOS 14+ (containerized Safari)
file("../Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"),
// macOS ≤13 (traditional path)
file("../Cookies/Cookies.binarycookies"),
},
// buildSources dispatches between the default and named-profile path layouts.
//
// macOS 14+ layout:
// - History, Cookie: per-profile (separate files per profile UUID)
// - Download: shared plist, filtered by DownloadEntryProfileUUIDStringKey at extract time
// - Bookmark: shared plist, attributed to default only (no per-entry UUID available)
// - Password: macOS Keychain (shared, not listed)
func buildSources(p profileContext) map[types.Category][]sourcePath {
if p.isDefault() {
return defaultSources(p)
}
return namedSources(p)
}
// defaultSources: cookies try macOS 14+ container first, then the ≤13 legacy path.
func defaultSources(p profileContext) map[types.Category][]sourcePath {
home := p.legacyHome
containerCookies := filepath.Join(p.container, "Cookies", "Cookies.binarycookies")
legacyCookies := filepath.Join(filepath.Dir(home), "Cookies", "Cookies.binarycookies")
return map[types.Category][]sourcePath{
types.History: {file(filepath.Join(home, "History.db"))},
types.Cookie: {file(containerCookies), file(legacyCookies)},
types.Bookmark: {file(filepath.Join(home, "Bookmarks.plist"))},
types.Download: {file(filepath.Join(home, "Downloads.plist"))},
}
}
// namedSources omits Bookmark (shared plist with no per-entry profile tag, so attributed to default).
// Download is included because Downloads.plist carries DownloadEntryProfileUUIDStringKey per entry;
// extractDownloads filters by owner UUID so default and named profiles each see their own downloads.
//
// LocalStorage slot for a follow-up PR:
//
// file(filepath.Join(p.container, "WebKit/WebsiteDataStore", p.uuidLower, "LocalStorage"))
func namedSources(p profileContext) map[types.Category][]sourcePath {
profileDir := filepath.Join(p.container, "Safari", "Profiles", p.uuidUpper)
webkitStore := filepath.Join(p.container, "WebKit", "WebsiteDataStore", p.uuidLower)
return map[types.Category][]sourcePath{
types.History: {file(filepath.Join(profileDir, "History.db"))},
types.Cookie: {file(filepath.Join(webkitStore, "Cookies", "Cookies.binarycookies"))},
types.Download: {file(filepath.Join(p.legacyHome, "Downloads.plist"))},
}
}
+36
View File
@@ -3,6 +3,7 @@ package safari
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
@@ -83,3 +84,38 @@ func createTestDB(t *testing.T, name string, schemas []string, inserts ...string
}
return path
}
// ---------------------------------------------------------------------------
// SafariTabs.db fixtures
// ---------------------------------------------------------------------------
// tabRow describes one profile entry to stamp into the fake SafariTabs.db.
type tabRow struct {
uuid string
title string
}
// writeSafariTabsDB creates a minimal SafariTabs.db at path containing only
// the bookmarks columns discoverSafariProfiles reads. Every row gets
// subtype=2 (profile record) so the production query picks it up.
func writeSafariTabsDB(t *testing.T, path string, rows []tabRow) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
db, err := sql.Open("sqlite", path)
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(`CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_uuid TEXT,
title TEXT,
subtype INTEGER DEFAULT 0
)`)
require.NoError(t, err)
for _, r := range rows {
_, err = db.Exec(`INSERT INTO bookmarks (external_uuid, title, subtype) VALUES (?, ?, 2)`, r.uuid, r.title)
require.NoError(t, err)
}
}