diff --git a/browser/safari/extract_bookmark.go b/browser/safari/extract_bookmark.go new file mode 100644 index 0000000..68c1c0c --- /dev/null +++ b/browser/safari/extract_bookmark.go @@ -0,0 +1,84 @@ +package safari + +import ( + "fmt" + "os" + "time" + + "github.com/moond4rk/plist" + + "github.com/moond4rk/hackbrowserdata/types" +) + +// safariBookmark mirrors the plist structure of Safari's Bookmarks.plist. +type safariBookmark struct { + Type string `plist:"WebBookmarkType"` + Title string `plist:"Title"` + URLString string `plist:"URLString"` + URIDictionary uriDictionary `plist:"URIDictionary"` + Children []safariBookmark `plist:"Children"` +} + +type uriDictionary struct { + Title string `plist:"title"` +} + +const ( + bookmarkTypeLeaf = "WebBookmarkTypeLeaf" + bookmarkTypeList = "WebBookmarkTypeList" +) + +func extractBookmarks(path string) ([]types.BookmarkEntry, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open bookmarks: %w", err) + } + defer f.Close() + + var root safariBookmark + if err := plist.NewDecoder(f).Decode(&root); err != nil { + return nil, fmt.Errorf("decode bookmarks: %w", err) + } + + var bookmarks []types.BookmarkEntry + walkBookmarks(root.Children, "", &bookmarks) + return bookmarks, nil +} + +// walkBookmarks recursively traverses the bookmark tree, collecting leaf entries. +func walkBookmarks(nodes []safariBookmark, folder string, out *[]types.BookmarkEntry) { + for i, node := range nodes { + switch node.Type { + case bookmarkTypeLeaf: + title := node.URIDictionary.Title + if title == "" { + title = node.Title + } + if node.URLString == "" { + continue + } + *out = append(*out, types.BookmarkEntry{ + ID: int64(i), + Name: title, + URL: node.URLString, + Folder: folder, + Type: "bookmark", + CreatedAt: time.Time{}, + }) + case bookmarkTypeList: + name := node.Title + if name == "com.apple.ReadingList" { + name = "ReadingList" + } + walkBookmarks(node.Children, name, out) + } + } +} + +func countBookmarks(path string) (int, error) { + bookmarks, err := extractBookmarks(path) + if err != nil { + return 0, err + } + return len(bookmarks), nil +} diff --git a/browser/safari/extract_bookmark_test.go b/browser/safari/extract_bookmark_test.go new file mode 100644 index 0000000..484240c --- /dev/null +++ b/browser/safari/extract_bookmark_test.go @@ -0,0 +1,179 @@ +package safari + +import ( + "os" + "path/filepath" + "testing" + + "github.com/moond4rk/plist" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func buildTestBookmarksPlist(t *testing.T, root safariBookmark) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Bookmarks.plist") + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + require.NoError(t, plist.NewBinaryEncoder(f).Encode(root)) + return path +} + +func TestExtractBookmarks(t *testing.T) { + root := safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + { + Type: bookmarkTypeList, + Title: "BookmarksBar", + Children: []safariBookmark{ + { + Type: bookmarkTypeLeaf, + URLString: "https://github.com", + URIDictionary: uriDictionary{Title: "GitHub"}, + }, + { + Type: bookmarkTypeLeaf, + URLString: "https://go.dev", + URIDictionary: uriDictionary{Title: "Go"}, + }, + }, + }, + { + Type: bookmarkTypeList, + Title: "BookmarksMenu", + Children: []safariBookmark{ + { + Type: bookmarkTypeLeaf, + URLString: "https://example.com", + URIDictionary: uriDictionary{Title: "Example"}, + }, + }, + }, + }, + } + + path := buildTestBookmarksPlist(t, root) + bookmarks, err := extractBookmarks(path) + require.NoError(t, err) + require.Len(t, bookmarks, 3) + + // Verify folder assignment + assert.Equal(t, "GitHub", bookmarks[0].Name) + assert.Equal(t, "https://github.com", bookmarks[0].URL) + assert.Equal(t, "BookmarksBar", bookmarks[0].Folder) + + assert.Equal(t, "Go", bookmarks[1].Name) + assert.Equal(t, "BookmarksBar", bookmarks[1].Folder) + + assert.Equal(t, "Example", bookmarks[2].Name) + assert.Equal(t, "BookmarksMenu", bookmarks[2].Folder) +} + +func TestExtractBookmarks_ReadingList(t *testing.T) { + root := safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + { + Type: bookmarkTypeList, + Title: "com.apple.ReadingList", + Children: []safariBookmark{ + { + Type: bookmarkTypeLeaf, + URLString: "https://blog.example.com/post", + URIDictionary: uriDictionary{Title: "Blog Post"}, + }, + }, + }, + }, + } + + path := buildTestBookmarksPlist(t, root) + bookmarks, err := extractBookmarks(path) + require.NoError(t, err) + require.Len(t, bookmarks, 1) + assert.Equal(t, "ReadingList", bookmarks[0].Folder) +} + +func TestExtractBookmarks_SkipsEmptyURL(t *testing.T) { + root := safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + { + Type: bookmarkTypeLeaf, + URLString: "", // no URL, should be skipped + URIDictionary: uriDictionary{Title: "Empty"}, + }, + { + Type: bookmarkTypeLeaf, + URLString: "https://valid.com", + URIDictionary: uriDictionary{Title: "Valid"}, + }, + }, + } + + path := buildTestBookmarksPlist(t, root) + bookmarks, err := extractBookmarks(path) + require.NoError(t, err) + require.Len(t, bookmarks, 1) + assert.Equal(t, "Valid", bookmarks[0].Name) +} + +func TestExtractBookmarks_NestedFolders(t *testing.T) { + root := safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + { + Type: bookmarkTypeList, + Title: "Work", + Children: []safariBookmark{ + { + Type: bookmarkTypeList, + Title: "Projects", + Children: []safariBookmark{ + {Type: bookmarkTypeLeaf, URLString: "https://deep.com", URIDictionary: uriDictionary{Title: "Deep"}}, + }, + }, + {Type: bookmarkTypeLeaf, URLString: "https://shallow.com", URIDictionary: uriDictionary{Title: "Shallow"}}, + }, + }, + }, + } + + path := buildTestBookmarksPlist(t, root) + bookmarks, err := extractBookmarks(path) + require.NoError(t, err) + require.Len(t, bookmarks, 2) + + // Nested leaf gets the immediate parent folder name + assert.Equal(t, "Deep", bookmarks[0].Name) + assert.Equal(t, "Projects", bookmarks[0].Folder) + + assert.Equal(t, "Shallow", bookmarks[1].Name) + assert.Equal(t, "Work", bookmarks[1].Folder) +} + +func TestCountBookmarks(t *testing.T) { + root := safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + {Type: bookmarkTypeLeaf, URLString: "https://a.com", URIDictionary: uriDictionary{Title: "A"}}, + {Type: bookmarkTypeLeaf, URLString: "https://b.com", URIDictionary: uriDictionary{Title: "B"}}, + }, + } + + path := buildTestBookmarksPlist(t, root) + count, err := countBookmarks(path) + require.NoError(t, err) + assert.Equal(t, 2, count) +} + +func TestExtractBookmarks_Empty(t *testing.T) { + root := safariBookmark{Type: bookmarkTypeList} + path := buildTestBookmarksPlist(t, root) + + bookmarks, err := extractBookmarks(path) + require.NoError(t, err) + assert.Empty(t, bookmarks) +} diff --git a/browser/safari/extract_download.go b/browser/safari/extract_download.go new file mode 100644 index 0000000..7317387 --- /dev/null +++ b/browser/safari/extract_download.go @@ -0,0 +1,54 @@ +package safari + +import ( + "fmt" + "os" + + "github.com/moond4rk/plist" + + "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"` +} + +func extractDownloads(path string) ([]types.DownloadEntry, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open downloads: %w", err) + } + defer f.Close() + + var dl safariDownloads + if err := plist.NewDecoder(f).Decode(&dl); err != nil { + return nil, fmt.Errorf("decode downloads: %w", err) + } + + var downloads []types.DownloadEntry + for _, d := range dl.DownloadHistory { + downloads = append(downloads, types.DownloadEntry{ + URL: d.URL, + TargetPath: d.Path, + TotalBytes: int64(d.TotalBytes), + }) + } + return downloads, nil +} + +func countDownloads(path string) (int, error) { + downloads, err := extractDownloads(path) + if err != nil { + return 0, err + } + return len(downloads), nil +} diff --git a/browser/safari/extract_download_test.go b/browser/safari/extract_download_test.go new file mode 100644 index 0000000..0147aad --- /dev/null +++ b/browser/safari/extract_download_test.go @@ -0,0 +1,74 @@ +package safari + +import ( + "os" + "path/filepath" + "testing" + + "github.com/moond4rk/plist" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func buildTestDownloadsPlist(t *testing.T, dl safariDownloads) string { + t.Helper() + path := filepath.Join(t.TempDir(), "Downloads.plist") + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + require.NoError(t, plist.NewBinaryEncoder(f).Encode(dl)) + return path +} + +func TestExtractDownloads(t *testing.T) { + 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, + }, + }, + } + + path := buildTestDownloadsPlist(t, dl) + downloads, err := extractDownloads(path) + require.NoError(t, err) + require.Len(t, downloads, 2) + + 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) + + assert.Equal(t, "https://go.dev/dl/go1.20.tar.gz", downloads[1].URL) + assert.Equal(t, int64(98765432), downloads[1].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}, + }, + } + + path := buildTestDownloadsPlist(t, dl) + count, err := countDownloads(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestExtractDownloads_Empty(t *testing.T) { + dl := safariDownloads{} + path := buildTestDownloadsPlist(t, dl) + + downloads, err := extractDownloads(path) + require.NoError(t, err) + assert.Empty(t, downloads) +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index a35e9f5..31b3a03 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -110,6 +110,10 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p data.Histories, err = extractHistories(path) case types.Cookie: data.Cookies, err = extractCookies(path) + case types.Bookmark: + data.Bookmarks, err = extractBookmarks(path) + case types.Download: + data.Downloads, err = extractDownloads(path) default: return } @@ -127,6 +131,10 @@ func (b *Browser) countCategory(cat types.Category, path string) int { count, err = countHistories(path) case types.Cookie: count, err = countCookies(path) + case types.Bookmark: + count, err = countBookmarks(path) + case types.Download: + count, err = countDownloads(path) default: // Unsupported categories silently return 0. } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index 9ecd553..3d8f80c 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -142,6 +142,28 @@ func TestCountCategory(t *testing.T) { assert.Equal(t, 2, b.countCategory(types.Cookie, path)) }) + t.Run("Bookmark", func(t *testing.T) { + path := buildTestBookmarksPlist(t, safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + {Type: bookmarkTypeLeaf, URLString: "https://a.com", URIDictionary: uriDictionary{Title: "A"}}, + {Type: bookmarkTypeLeaf, URLString: "https://b.com", URIDictionary: uriDictionary{Title: "B"}}, + }, + }) + b := &Browser{} + assert.Equal(t, 2, b.countCategory(types.Bookmark, path)) + }) + + t.Run("Download", func(t *testing.T) { + path := buildTestDownloadsPlist(t, safariDownloads{ + DownloadHistory: []safariDownloadEntry{ + {URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 100}, + }, + }) + b := &Browser{} + assert.Equal(t, 1, b.countCategory(types.Download, path)) + }) + t.Run("UnsupportedCategory", func(t *testing.T) { b := &Browser{} assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused")) @@ -186,6 +208,37 @@ func TestExtractCategory(t *testing.T) { assert.True(t, data.Cookies[0].IsHTTPOnly) }) + t.Run("Bookmark", func(t *testing.T) { + path := buildTestBookmarksPlist(t, safariBookmark{ + Type: bookmarkTypeList, + Children: []safariBookmark{ + {Type: bookmarkTypeLeaf, URLString: "https://github.com", URIDictionary: uriDictionary{Title: "GitHub"}}, + }, + }) + b := &Browser{} + data := &types.BrowserData{} + b.extractCategory(data, types.Bookmark, path) + + require.Len(t, data.Bookmarks, 1) + assert.Equal(t, "GitHub", data.Bookmarks[0].Name) + assert.Equal(t, "https://github.com", data.Bookmarks[0].URL) + }) + + t.Run("Download", func(t *testing.T) { + path := buildTestDownloadsPlist(t, safariDownloads{ + DownloadHistory: []safariDownloadEntry{ + {URL: "https://example.com/file.zip", Path: "/tmp/file.zip", TotalBytes: 1024}, + }, + }) + b := &Browser{} + data := &types.BrowserData{} + b.extractCategory(data, types.Download, path) + + require.Len(t, data.Downloads, 1) + assert.Equal(t, "https://example.com/file.zip", data.Downloads[0].URL) + assert.Equal(t, int64(1024), data.Downloads[0].TotalBytes) + }) + t.Run("UnsupportedCategory", func(t *testing.T) { b := &Browser{} data := &types.BrowserData{} diff --git a/browser/safari/source.go b/browser/safari/source.go index 6548f43..fbeeed3 100644 --- a/browser/safari/source.go +++ b/browser/safari/source.go @@ -19,7 +19,9 @@ func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel // 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.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"), diff --git a/go.mod b/go.mod index 1b8154e..554dbcb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 github.com/moond4rk/binarycookies v1.0.2 github.com/moond4rk/keychainbreaker v0.2.5 + github.com/moond4rk/plist v1.2.0 github.com/otiai10/copy v1.14.1 github.com/ppacher/go-dbus-keyring v1.0.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 5d9d65a..45b639b 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/moond4rk/binarycookies v1.0.2 h1:moXSHYOj/V4Z1vEsEjHjdNML+4JcflOpxx53 github.com/moond4rk/binarycookies v1.0.2/go.mod h1:iAJr8L7ZgzTKaZhzEZmzjuOFnAoIHaqHFzvn58h1b7c= github.com/moond4rk/keychainbreaker v0.2.5 h1:1f2qmgpt1sl+mXA8DTW9nnVhzo4oGO08bnkXu70DL04= github.com/moond4rk/keychainbreaker v0.2.5/go.mod h1:VVx2VXwL2EGhuU2WBD67w66JCKKqLFXGJg91y3FY4f0= +github.com/moond4rk/plist v1.2.0 h1:gmIP7R96ZZzvE823NMQTscNERVrPVJWLoPFHd4QWdEk= +github.com/moond4rk/plist v1.2.0/go.mod h1:/1FduRi9JULFEUYcXvW88vOwHgOiEH+YNtvtmdh6GOs= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=