mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user