From d0dafc2a0fb41b9c8679d21da91ffce9196e437c Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Tue, 21 Apr 2026 00:23:40 +0800 Subject: [PATCH 1/2] feat(safari): multi-profile support --- browser/safari/extract_download.go | 34 +++- browser/safari/extract_download_test.go | 52 +++-- browser/safari/profiles.go | 175 ++++++++++++++++ browser/safari/profiles_test.go | 259 ++++++++++++++++++++++++ browser/safari/safari.go | 95 +++++---- browser/safari/safari_test.go | 59 +++++- browser/safari/source.go | 65 ++++-- browser/safari/testutil_test.go | 36 ++++ 8 files changed, 671 insertions(+), 104 deletions(-) create mode 100644 browser/safari/profiles.go create mode 100644 browser/safari/profiles_test.go diff --git a/browser/safari/extract_download.go b/browser/safari/extract_download.go index 73173878..c3fb117c 100644 --- a/browser/safari/extract_download.go +++ b/browser/safari/extract_download.go @@ -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 +} diff --git a/browser/safari/extract_download_test.go b/browser/safari/extract_download_test.go index 0147aad3..6967151a 100644 --- a/browser/safari/extract_download_test.go +++ b/browser/safari/extract_download_test.go @@ -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) } diff --git a/browser/safari/profiles.go b/browser/safari/profiles.go new file mode 100644 index 00000000..91b1d16d --- /dev/null +++ b/browser/safari/profiles.go @@ -0,0 +1,175 @@ +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/) and lowercase +// (WebKit/WebsiteDataStore/) 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)) + } + 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-". +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]++ + } +} diff --git a/browser/safari/profiles_test.go b/browser/safari/profiles_test.go new file mode 100644 index 00000000..6cc3c066 --- /dev/null +++ b/browser/safari/profiles_test.go @@ -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// 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) +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index b3f9a080..bf9e9e6a 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -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) } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 3d8f80cd..b67501c0 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -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,16 @@ func TestResolveSourcePaths(t *testing.T) { dir := t.TempDir() mkFile(t, dir, "History.db") - resolved := resolveSourcePaths(safariSources, dir) + sources := buildSources(profileContext{legacyHome: 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) + sources := buildSources(profileContext{legacyHome: t.TempDir()}) + assert.Empty(t, resolveSourcePaths(sources)) } // --------------------------------------------------------------------------- diff --git a/browser/safari/source.go b/browser/safari/source.go index fbeeed3e..594059e4 100644 --- a/browser/safari/source.go +++ b/browser/safari/source.go @@ -6,26 +6,53 @@ 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)} } - -// 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"), - }, +func file(abs string) sourcePath { return sourcePath{abs: abs} } + +// 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 shared categories (Bookmark, Download) — those are attributed to the default profile. +// +// 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"))}, + } } diff --git a/browser/safari/testutil_test.go b/browser/safari/testutil_test.go index bb9eeac1..79fea7a7 100644 --- a/browser/safari/testutil_test.go +++ b/browser/safari/testutil_test.go @@ -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) + } +} From 5d5ec1cc08be1f86fbe1cbc10cc3930890b4d4ef Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Tue, 21 Apr 2026 00:46:52 +0800 Subject: [PATCH 2/2] fix(safari): address review comments on #581 --- browser/safari/profiles.go | 3 +++ browser/safari/safari_test.go | 5 +++-- browser/safari/source.go | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/browser/safari/profiles.go b/browser/safari/profiles.go index 91b1d16d..69ac49d6 100644 --- a/browser/safari/profiles.go +++ b/browser/safari/profiles.go @@ -116,6 +116,9 @@ func readNamedProfilesFromDB(container string) ([]profileContext, error) { } 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 } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index b67501c0..1212018d 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -130,7 +130,7 @@ func TestResolveSourcePaths(t *testing.T) { dir := t.TempDir() mkFile(t, dir, "History.db") - sources := buildSources(profileContext{legacyHome: 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) @@ -138,7 +138,8 @@ func TestResolveSourcePaths(t *testing.T) { } func TestResolveSourcePaths_Empty(t *testing.T) { - sources := buildSources(profileContext{legacyHome: t.TempDir()}) + dir := t.TempDir() + sources := buildSources(profileContext{legacyHome: dir, container: deriveContainerRoot(dir)}) assert.Empty(t, resolveSourcePaths(sources)) } diff --git a/browser/safari/source.go b/browser/safari/source.go index 594059e4..e33b2f76 100644 --- a/browser/safari/source.go +++ b/browser/safari/source.go @@ -41,7 +41,9 @@ func defaultSources(p profileContext) map[types.Category][]sourcePath { } } -// namedSources omits shared categories (Bookmark, Download) — those are attributed to the default profile. +// 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: //