mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat(safari): multi-profile support (#581)
* feat(safari): multi-profile support
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]++
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"))},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user