feat: add Safari bookmark and download extraction from plist (#567)

* feat: add Safari bookmark and download extraction from plist files
* test: add nested folder test for bookmark tree traversal
Part of #565
This commit is contained in:
Roger
2026-04-12 01:50:54 +08:00
committed by GitHub
parent 7bf1759dd9
commit d105a1f488
9 changed files with 458 additions and 1 deletions
+84
View File
@@ -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
}
+179
View File
@@ -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)
}
+54
View File
@@ -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
}
+74
View File
@@ -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)
}
+8
View File
@@ -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.
}
+53
View File
@@ -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{}
+3 -1
View File
@@ -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"),
+1
View File
@@ -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
+2
View File
@@ -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=