Files
HackBrowserData/browser/safari/source.go
T
Roger 7a5db25b4f feat(safari): localstorage extraction (#582)
* feat(safari): localstorage extraction

Extracts Safari 17+ localStorage from WebKit's nested layout —
WebsiteDataStore/<uuid>/Origins/<top-hash>/<frame-hash>/LocalStorage/
localstorage.sqlite3 for named profiles, WebsiteData/Default for the
default profile. Parses the binary SecurityOrigin serialization
(length-prefixed scheme+host plus 0x00 default-port or 0x01 <uint16_le>
explicit-port section) and decodes UTF-16 LE ItemTable value BLOBs,
capping oversized values at 2048 bytes to match the Chromium extractor.
Reports the frame origin URL so partitioned third-party storage is
attributed to the iframe origin JavaScript actually sees.

Closes the remaining LocalStorage checkbox in #565.

* docs(safari): add RFC-011 data storage

Documents Safari's profile structure, per-category file layouts, and
storage formats including the Safari 17+ nested WebKit Origins
localStorage layout and binary SecurityOrigin serialization. Defers
Keychain credential extraction to RFC-006 §7 and notes the cross-browser
differences (plaintext cookies, plist bookmarks/downloads, Core Data
epoch timestamps, partitioned storage).

* fix(safari): latin-1 origin decoding, NULL key skip, count fast-path

- Decode originEncASCII via decodeLatin1 so high-byte records preserve
  their ISO-8859-1 meaning instead of being interpreted as UTF-8.
  Matches the pattern in chromium/extract_storage.go.
- Skip ItemTable rows where key is NULL — SQLite's UNIQUE constraint
  permits multiple NULLs, and silently lowering them to empty strings
  would collide with legitimate empty-string keys.
- countLocalStorage now walks origin dirs and runs SELECT COUNT(key)
  per localstorage.sqlite3 instead of fully decoding every value.
  COUNT(key) naturally excludes NULLs, keeping count and extract
  symmetric.

Addresses Copilot review feedback on #582.

* fix(safari): round-2 review — WAL replay, stable ordering, error context

- Drop immutable=1 on temp-copy SQLite opens in readLocalStorageFile /
  countLocalStorageFile. Session.Acquire copies the -wal / -shm sidecars,
  so mode=ro alone lets SQLite replay WAL on the ephemeral copy and
  surface entries Safari committed to WAL but hasn't checkpointed yet.
  Live-file reads in profiles.go keep immutable=1 as before.
- Order ItemTable query by (key, rowid) for deterministic exports across
  runs and SQLite versions.
- Wrap os.ReadFile / os.ReadDir errors with the offending path so
  multi-origin debug logs stay scannable.
- RFC-011 §7 rewritten to explain the live-vs-temp split.
- New regression test asserts ORDER BY surfaces rows in key order.

Addresses round-2 Copilot review on #582.
2026-04-21 20:47:11 +08:00

66 lines
3.0 KiB
Go

package safari
import (
"path/filepath"
"github.com/moond4rk/hackbrowserdata/types"
)
type sourcePath struct {
abs string
isDir bool
}
func file(abs string) sourcePath { return sourcePath{abs: abs} }
func dir(abs string) sourcePath { return sourcePath{abs: abs, isDir: true} }
// 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.
// LocalStorage for the default profile lives under WebsiteData/Default — the pre-profile-era
// WebKit store that stays readable even after profiles are introduced.
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")
defaultLocalStorage := filepath.Join(p.container, "WebKit", "WebsiteData", "Default")
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"))},
types.LocalStorage: {dir(defaultLocalStorage)},
}
}
// 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 lives under WebKit/WebsiteDataStore/<uuidLower>/Origins — Safari 17+ uses a nested
// <top-frame-hash>/<frame-hash>/LocalStorage/localstorage.sqlite3 layout; the flat
// WebsiteDataStore/<uuid>/LocalStorage directory from older builds is empty on modern Safari.
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"))},
types.LocalStorage: {dir(filepath.Join(webkitStore, "Origins"))},
}
}