mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-06-10 20:07:46 +02:00
feat(cli): add archive command for cross-host data transport (#610)
* feat(cli): add archive command for cross-host data transport * fix(archive): correct flat-layout path and entry-count wording * refactor(archive): rename BuildArchive to WriteArchive
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/browser/chromium"
|
||||
"github.com/moond4rk/hackbrowserdata/filemanager"
|
||||
"github.com/moond4rk/hackbrowserdata/log"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
|
||||
)
|
||||
|
||||
// Archivable is implemented by installations that can enumerate their decryption-relevant files for
|
||||
// cross-host transport (Chromium only).
|
||||
type Archivable interface {
|
||||
BrowserKey() string
|
||||
ArchiveSources(categories []types.Category) []chromium.ArchiveSource
|
||||
}
|
||||
|
||||
// WriteArchive packs each browser's decryption-relevant files into a zip whose internal layout is
|
||||
// <browser-key>/<User Data layout>, so a restore can re-expand it and decrypt with a keys.json. Files
|
||||
// are staged through a locked-file session first because Windows holds exclusive SQLite locks. Returns
|
||||
// the number of source entries staged (a directory source counts once).
|
||||
func WriteArchive(browsers []Browser, categories []types.Category, outPath string) (int, error) {
|
||||
session, err := filemanager.NewSession()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer session.Cleanup()
|
||||
|
||||
staging := session.TempDir()
|
||||
seen := make(map[string]bool)
|
||||
count := 0
|
||||
for _, b := range browsers {
|
||||
archivable, ok := b.(Archivable)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := archivable.BrowserKey()
|
||||
for _, src := range archivable.ArchiveSources(categories) {
|
||||
entry := key + "/" + src.LayoutRel
|
||||
if seen[entry] {
|
||||
continue
|
||||
}
|
||||
seen[entry] = true
|
||||
|
||||
dst := filepath.Join(staging, key, filepath.FromSlash(src.LayoutRel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
log.Warnf("archive: %s: %v", entry, err)
|
||||
continue
|
||||
}
|
||||
if err := session.Acquire(src.AbsPath, dst, src.IsDir); err != nil {
|
||||
log.Warnf("archive: acquire %s: %v", entry, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, fmt.Errorf("no decryption-relevant files found to archive")
|
||||
}
|
||||
if err := fileutil.ZipDir(outPath, staging); err != nil {
|
||||
return 0, fmt.Errorf("write archive %s: %w", outPath, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/browser/chromium"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
|
||||
)
|
||||
|
||||
// TestWriteArchive_RoundTrip exercises the archive path: ArchiveSources -> WriteArchive (stage+zip)
|
||||
// -> Unzip, asserting the archive's internal layout is <key>/<User Data layout>.
|
||||
func TestWriteArchive_RoundTrip(t *testing.T) {
|
||||
origin := t.TempDir()
|
||||
def := filepath.Join(origin, "Default")
|
||||
if err := os.MkdirAll(def, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(def, "Preferences"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(def, "History"), []byte("hist"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(origin, "Local State"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := chromium.NewBrowser(types.BrowserConfig{Key: "chrome", Name: "chrome", Kind: types.Chromium, UserDataDir: origin})
|
||||
if err != nil || b == nil {
|
||||
t.Fatalf("NewBrowser: b=%v err=%v", b, err)
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(t.TempDir(), "data.zip")
|
||||
n, err := WriteArchive([]Browser{b}, []types.Category{types.History}, zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteArchive: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("WriteArchive captured 0 entries")
|
||||
}
|
||||
|
||||
extracted := t.TempDir()
|
||||
if err := fileutil.Unzip(zipPath, extracted); err != nil {
|
||||
t.Fatalf("Unzip: %v", err)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("chrome", "Default", "History"),
|
||||
filepath.Join("chrome", "Default", "Preferences"),
|
||||
filepath.Join("chrome", "Local State"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(extracted, rel)); err != nil {
|
||||
t.Errorf("expected %s in archive layout: %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
"github.com/moond4rk/hackbrowserdata/utils/fileutil"
|
||||
)
|
||||
|
||||
// ArchiveSource is one decryption-relevant file or directory plus its path inside the browser's
|
||||
// User Data tree (forward-slash), so an archive can be re-expanded into a working profile layout.
|
||||
type ArchiveSource struct {
|
||||
AbsPath string
|
||||
LayoutRel string
|
||||
IsDir bool
|
||||
}
|
||||
|
||||
// installationFiles live at the User Data root (shared across profiles); archived for fidelity even
|
||||
// though keys.json-based restore does not read them.
|
||||
var installationFiles = []string{"Local State"}
|
||||
|
||||
// ArchiveSources lists the files an archive must capture for the given categories: the User Data root
|
||||
// files (Local State), every resolved category source per profile, plus each profile's Preferences
|
||||
// marker so a restore can rediscover the profile. LayoutRel is forward-slash, relative to the root.
|
||||
func (b *Browser) ArchiveSources(categories []types.Category) []ArchiveSource {
|
||||
var out []ArchiveSource
|
||||
for _, name := range installationFiles {
|
||||
abs := filepath.Join(b.cfg.UserDataDir, name)
|
||||
if fileutil.FileExists(abs) {
|
||||
out = append(out, ArchiveSource{AbsPath: abs, LayoutRel: name, IsDir: false})
|
||||
}
|
||||
}
|
||||
for _, p := range b.profiles {
|
||||
// Flat-layout installs hold data directly under UserDataDir (profileDir == root); skip the
|
||||
// basename so the archive matches the real layout instead of inserting a phantom level.
|
||||
profileRel := ""
|
||||
if p.profileDir != b.cfg.UserDataDir {
|
||||
profileRel = filepath.Base(p.profileDir)
|
||||
}
|
||||
for _, marker := range profileMarkers {
|
||||
abs := filepath.Join(p.profileDir, marker)
|
||||
if fileutil.FileExists(abs) {
|
||||
out = append(out, ArchiveSource{
|
||||
AbsPath: abs,
|
||||
LayoutRel: path.Join(profileRel, marker),
|
||||
IsDir: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, cat := range categories {
|
||||
rp, ok := p.sourcePaths[cat]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, ArchiveSource{
|
||||
AbsPath: rp.absPath,
|
||||
LayoutRel: path.Join(profileRel, rp.rel),
|
||||
IsDir: rp.isDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
func TestArchiveSources_ForwardSlashLayout(t *testing.T) {
|
||||
udd := t.TempDir()
|
||||
networkDir := filepath.Join(udd, "Default", "Network")
|
||||
if err := os.MkdirAll(networkDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(udd, "Default", "Preferences"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(networkDir, "Cookies"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(udd, "Local State"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := NewBrowser(types.BrowserConfig{Key: "chrome", Name: "chrome", Kind: types.Chromium, UserDataDir: udd})
|
||||
if err != nil || b == nil {
|
||||
t.Fatalf("NewBrowser: b=%v err=%v", b, err)
|
||||
}
|
||||
|
||||
srcs := b.ArchiveSources([]types.Category{types.Cookie})
|
||||
|
||||
var gotCookie, gotMarker, gotLocalState bool
|
||||
for _, s := range srcs {
|
||||
if strings.Contains(s.LayoutRel, `\`) {
|
||||
t.Errorf("LayoutRel must be forward-slash, got %q", s.LayoutRel)
|
||||
}
|
||||
switch s.LayoutRel {
|
||||
case "Default/Network/Cookies":
|
||||
gotCookie = true
|
||||
case "Default/Preferences":
|
||||
gotMarker = true
|
||||
case "Local State":
|
||||
gotLocalState = true
|
||||
}
|
||||
}
|
||||
if !gotCookie {
|
||||
t.Errorf("missing Cookies entry with layout path, got %+v", srcs)
|
||||
}
|
||||
if !gotMarker {
|
||||
t.Errorf("missing Preferences marker entry (needed for restore profile discovery), got %+v", srcs)
|
||||
}
|
||||
if !gotLocalState {
|
||||
t.Errorf("missing Local State entry (User Data root file), got %+v", srcs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveSources_FlatLayoutNoExtraLevel(t *testing.T) {
|
||||
// Flat-layout install: data lives directly under UserDataDir with no Default/ subdir, so
|
||||
// discoverProfiles falls back to UserDataDir itself as the profile (profileDir == root).
|
||||
udd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(udd, "History"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := NewBrowser(types.BrowserConfig{Key: "opera", Name: "opera", Kind: types.Chromium, UserDataDir: udd})
|
||||
if err != nil || b == nil {
|
||||
t.Fatalf("NewBrowser: b=%v err=%v", b, err)
|
||||
}
|
||||
|
||||
srcs := b.ArchiveSources([]types.Category{types.History})
|
||||
|
||||
phantom := filepath.Base(udd) + "/"
|
||||
var gotHistory bool
|
||||
for _, s := range srcs {
|
||||
if strings.HasPrefix(s.LayoutRel, phantom) {
|
||||
t.Errorf("flat layout must not insert a %q level, got %q", phantom, s.LayoutRel)
|
||||
}
|
||||
if s.LayoutRel == "History" {
|
||||
gotHistory = true
|
||||
}
|
||||
}
|
||||
if !gotHistory {
|
||||
t.Errorf("expected History at archive root, got %+v", srcs)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
|
||||
func (b *Browser) SetRetrievers(r masterkey.Retrievers) { b.retrievers = r }
|
||||
|
||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||
func (b *Browser) BrowserKey() string { return b.cfg.Key }
|
||||
func (b *Browser) UserDataDir() string { return b.cfg.UserDataDir }
|
||||
|
||||
// Profiles returns the identity of every profile in this installation.
|
||||
@@ -204,9 +205,11 @@ func hasAnySource(sources map[types.Category][]sourcePath, dir string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// resolvedPath holds the absolute path and type for a discovered source.
|
||||
// resolvedPath holds the absolute path, the slash-relative source path, and the type of a discovered
|
||||
// source. rel is retained (not just absPath) so archive can reproduce the User Data layout.
|
||||
type resolvedPath struct {
|
||||
absPath string
|
||||
rel string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
@@ -222,7 +225,7 @@ func resolveSourcePaths(sources map[types.Category][]sourcePath, profileDir stri
|
||||
continue
|
||||
}
|
||||
if sp.isDir == info.IsDir() {
|
||||
resolved[cat] = resolvedPath{abs, sp.isDir}
|
||||
resolved[cat] = resolvedPath{absPath: abs, rel: sp.rel, isDir: sp.isDir}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/masterkey"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
@@ -14,8 +12,10 @@ type sourcePath struct {
|
||||
isDir bool // true for directory targets (LevelDB, Session Storage)
|
||||
}
|
||||
|
||||
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
|
||||
func dir(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: true} }
|
||||
// rel stays slash-canonical (e.g. "Network/Cookies"); filepath.Join converts at resolve time, and
|
||||
// archive reuses it verbatim as a forward-slash zip entry name.
|
||||
func file(rel string) sourcePath { return sourcePath{rel: rel, isDir: false} }
|
||||
func dir(rel string) sourcePath { return sourcePath{rel: rel, isDir: true} }
|
||||
|
||||
// chromiumSources defines the standard Chromium file layout.
|
||||
// Each category maps to one or more candidate paths tried in priority order;
|
||||
|
||||
Reference in New Issue
Block a user