feat(restore): cross-platform restore via dump engine rebuild (#606)

Restore previously required the dump's origin OS, overlaying keys onto locally-discovered browsers. It now rebuilds Chromium engines from the dump's vaults (v2 adds engine kind), so copied data or an archive zip decrypts on any OS.
This commit is contained in:
moonD4rk
2026-06-07 22:03:46 +08:00
parent cd0b2daaf3
commit 6d0efadb59
11 changed files with 496 additions and 189 deletions
+3
View File
@@ -91,9 +91,12 @@ func discoverFromConfigs(configs []types.BrowserConfig, opts DiscoverOptions) ([
}
// KeyManager is implemented by installations accepting external master-key retrievers (Chromium only).
// BrowserKey/Kind expose the identity a portable dump needs to rebuild the engine off the platform table.
type KeyManager interface {
SetRetrievers(masterkey.Retrievers)
ExportKeys() (masterkey.MasterKeys, error)
BrowserKey() string
Kind() types.BrowserKind
}
// KeychainPasswordReceiver is implemented by installations that need the macOS login password (Safari only).
+20 -6
View File
@@ -54,9 +54,10 @@ func NewBrowser(cfg types.BrowserConfig) (*Browser, error) {
// Extract; unused tiers stay nil.
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 }
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 }
func (b *Browser) Kind() types.BrowserKind { return b.cfg.Kind }
// Profiles returns the identity of every profile in this installation.
func (b *Browser) Profiles() []types.Profile {
@@ -162,12 +163,25 @@ func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePat
}
}
// Flat layout fallback (older Opera): data files directly in userDataDir.
// Opera stores data alongside Local State in userDataDir itself, so check
// for any known source file instead of Preferences.
// Flat layout (older Opera): data files directly under userDataDir with no profile subdir. Check the
// root before the subdir fallback so a stray source-bearing subdir can't suppress root discovery.
if len(profiles) == 0 && hasAnySource(sources, userDataDir) {
profiles = append(profiles, userDataDir)
}
// Restored/copied trees may omit the Preferences marker (it is no extraction source). When the marker
// scan and flat-layout check both find nothing, treat any source-bearing subdir as a profile.
if len(profiles) == 0 {
for _, e := range entries {
if !e.IsDir() || isSkippedDir(e.Name()) {
continue
}
dir := filepath.Join(userDataDir, e.Name())
if hasAnySource(sources, dir) {
profiles = append(profiles, dir)
}
}
}
return profiles
}
+44
View File
@@ -237,6 +237,50 @@ func TestNewBrowsers(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// discoverProfiles: fallback boundaries (marker-less copies, flat-layout precedence)
// ---------------------------------------------------------------------------
func TestDiscoverProfiles(t *testing.T) {
t.Run("markerless multi-subdir resolves all source-bearing dirs", func(t *testing.T) {
dir := t.TempDir()
mkFile(dir, "Default", "History")
mkFile(dir, "Profile 1", "History")
assert.Len(t, discoverProfiles(dir, chromiumSources), 2)
})
t.Run("marker present keeps fallback dormant", func(t *testing.T) {
dir := t.TempDir()
mkFile(dir, "Default", "Preferences")
mkFile(dir, "Default", "History")
mkFile(dir, "NotAProfile", "History")
got := discoverProfiles(dir, chromiumSources)
require.Len(t, got, 1)
assert.Equal(t, filepath.Join(dir, "Default"), got[0])
})
t.Run("skipped dir ignored by markerless fallback", func(t *testing.T) {
dir := t.TempDir()
mkFile(dir, "System Profile", "History")
assert.Empty(t, discoverProfiles(dir, chromiumSources))
})
t.Run("sourceless subdir ignored", func(t *testing.T) {
dir := t.TempDir()
mkDir(dir, "Crashpad")
assert.Empty(t, discoverProfiles(dir, chromiumSources))
})
t.Run("flat-layout root wins over source-bearing subdir", func(t *testing.T) {
dir := t.TempDir()
mkFile(dir, "History")
mkFile(dir, "Subthing", "History")
got := discoverProfiles(dir, chromiumSources)
require.Len(t, got, 1)
assert.Equal(t, dir, got[0], "flat-layout root must win; subdir must not hijack discovery")
})
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
+132 -34
View File
@@ -1,10 +1,14 @@
package browser
import (
"runtime"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/moond4rk/hackbrowserdata/log"
"github.com/moond4rk/hackbrowserdata/masterkey"
"github.com/moond4rk/hackbrowserdata/types"
)
// BuildDump exports one Vault per installation (Firefox/Safari, lacking KeyManager, are skipped).
@@ -28,8 +32,14 @@ func BuildDump(browsers []Browser) masterkey.Dump {
if !mk.HasAny() {
continue
}
kind, err := kindToDump(km.Kind())
if err != nil {
log.Warnf("dump-keys: %s: %v", b.BrowserName(), err)
continue
}
dump.Vaults = append(dump.Vaults, masterkey.Vault{
Browser: b.BrowserName(),
Browser: km.BrowserKey(),
Kind: kind,
UserDataDir: b.UserDataDir(),
Profiles: profileNames(b),
Keys: mk,
@@ -47,46 +57,102 @@ func profileNames(b Browser) []string {
return names
}
// ApplyDump overlays StaticRetrievers from dump onto matching installations (Firefox/Safari skipped).
// Match is by (BrowserName, UserDataDir); on miss — commonly a cross-host path mismatch (Windows vs
// POSIX, or a relocated dir via -p) — it falls back to the sole vault for that browser name. No match
// → warn and leave the platform retrievers in place.
func ApplyDump(browsers []Browser, dump masterkey.Dump) {
if dump.Host.OS != "" && dump.Host.OS != runtime.GOOS {
log.Infof("apply-keys: dump created on %s/%s; current host is %s/%s",
dump.Host.OS, dump.Host.Arch, runtime.GOOS, runtime.GOARCH)
// BuildFromDump reconstructs Chromium engines straight from a dump's vaults, rooted at copied data
// instead of the local platform table — this is what lets an analyst host decrypt a browser its OS
// never installs. filter is a browser key ("" or "all" = every vault); a filter matching no vault is
// an error rather than silent empty output.
//
// Data layout is resolved two ways. When dataDir holds per-key subdirs (the archive layout), each
// vault is rooted at dataDir/<key>. Otherwise dataDir is treated as one browser's User Data (a
// hand-copied folder), which is unambiguous only for a single vault — so filter must pick one.
func BuildFromDump(dump masterkey.Dump, dataDir, filter string) ([]Browser, error) {
filter = strings.ToLower(filter)
if filter == "all" {
filter = ""
}
vaultIndex := make(map[string]*masterkey.Vault, len(dump.Vaults))
vaultsByBrowser := make(map[string][]*masterkey.Vault)
for i := range dump.Vaults {
v := &dump.Vaults[i]
vaultIndex[v.Browser+"|"+v.UserDataDir] = v
vaultsByBrowser[v.Browser] = append(vaultsByBrowser[v.Browser], v)
}
for _, b := range browsers {
km, ok := b.(KeyManager)
if !ok {
var selected []masterkey.Vault
for _, v := range dump.Vaults {
if filter != "" && !strings.EqualFold(v.Browser, filter) {
continue
}
v, found := vaultIndex[b.BrowserName()+"|"+b.UserDataDir()]
if !found {
if candidates := vaultsByBrowser[b.BrowserName()]; len(candidates) == 1 {
v = candidates[0]
log.Infof("apply-keys: %s using sole vault for browser (dump path %q != local %q)",
b.BrowserName(), v.UserDataDir, b.UserDataDir())
found = true
selected = append(selected, v)
}
if filter != "" && len(selected) == 0 {
return nil, fmt.Errorf("no vault for browser %q in keys (have: %s)", filter, vaultKeys(dump))
}
if !dirExists(dataDir) {
return nil, fmt.Errorf("data dir %q does not exist", dataDir)
}
archiveLayout := isArchiveLayout(dataDir, selected)
if !archiveLayout && len(selected) > 1 {
return nil, fmt.Errorf("--data-dir %q has no per-browser subdir but keys has %d browsers; "+
"point it at the archive root, or use -b <browser> for one browser's User Data (have: %s)",
dataDir, len(selected), vaultKeys(dump))
}
var browsers []Browser
for _, v := range selected {
root := dataDir
if archiveLayout {
root = filepath.Join(dataDir, strings.ToLower(v.Browser))
if !dirExists(root) {
log.Warnf("restore: %s has no data under %s, skipping", v.Browser, root)
continue
}
}
if !found {
log.Warnf("apply-keys: %s no matching vault in dump", b.BrowserName())
kind, err := kindFromDump(v.Kind)
if err != nil {
log.Warnf("restore: %s: %v", v.Browser, err)
continue
}
km.SetRetrievers(masterkey.Retrievers{
V10: maybeStaticRetriever(v.Keys.V10),
V11: maybeStaticRetriever(v.Keys.V11),
V20: maybeStaticRetriever(v.Keys.V20),
})
cfg := types.BrowserConfig{
Key: strings.ToLower(v.Browser),
Name: v.Browser,
Kind: kind,
UserDataDir: root,
}
b, err := newBrowser(cfg)
if err != nil {
log.Errorf("restore: build %s: %v", v.Browser, err)
continue
}
if b == nil {
log.Warnf("restore: %s found no profiles under %s", v.Browser, root)
continue
}
if km, ok := b.(KeyManager); ok {
km.SetRetrievers(retrieversFromKeys(v.Keys))
}
browsers = append(browsers, b)
}
return browsers, nil
}
// isArchiveLayout reports whether dataDir uses the archive layout — one per-browser subdir named by
// the vault key — rather than a raw single-browser User Data copy.
func isArchiveLayout(dataDir string, vaults []masterkey.Vault) bool {
for _, v := range vaults {
if dirExists(filepath.Join(dataDir, strings.ToLower(v.Browser))) {
return true
}
}
return false
}
func vaultKeys(dump masterkey.Dump) string {
keys := make([]string, 0, len(dump.Vaults))
for _, v := range dump.Vaults {
keys = append(keys, strings.ToLower(v.Browser))
}
return strings.Join(keys, ", ")
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
// maybeStaticRetriever wraps non-empty key bytes as a StaticRetriever; an empty/nil key returns nil
@@ -97,3 +163,35 @@ func maybeStaticRetriever(key []byte) masterkey.Retriever {
}
return masterkey.NewStaticRetriever(key)
}
// retrieversFromKeys maps a vault's per-tier key bytes to static retrievers; an absent tier stays nil
// so NewMasterKeys keeps treating it as "not applicable".
func retrieversFromKeys(mk masterkey.MasterKeys) masterkey.Retrievers {
return masterkey.Retrievers{
V10: maybeStaticRetriever(mk.V10),
V11: maybeStaticRetriever(mk.V11),
V20: maybeStaticRetriever(mk.V20),
}
}
func kindToDump(k types.BrowserKind) (string, error) {
switch k {
case types.Chromium, types.ChromiumYandex, types.ChromiumOpera:
return k.String(), nil
default:
return "", fmt.Errorf("engine kind %s is not exportable", k)
}
}
// dumpableKinds are the engine kinds a vault may carry; kindFromDump reverses BrowserKind.String()
// over exactly these, keeping the wire vocabulary single-sourced in the types enum.
var dumpableKinds = []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera}
func kindFromDump(s string) (types.BrowserKind, error) {
for _, k := range dumpableKinds {
if k.String() == s {
return k, nil
}
}
return 0, fmt.Errorf("unknown engine kind %q", s)
}
+170 -105
View File
@@ -3,7 +3,10 @@ package browser
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/moond4rk/hackbrowserdata/masterkey"
@@ -44,6 +47,8 @@ func (m *mockBrowser) CountEntries(_ []types.Category) ([]types.CountResult, err
type mockChromiumBrowser struct {
mockBrowser
key string
kind types.BrowserKind
keys masterkey.MasterKeys
exportErr error
calls int
@@ -59,6 +64,15 @@ func (m *mockChromiumBrowser) ExportKeys() (masterkey.MasterKeys, error) {
return m.keys, m.exportErr
}
func (m *mockChromiumBrowser) BrowserKey() string {
if m.key != "" {
return m.key
}
return strings.ToLower(m.name)
}
func (m *mockChromiumBrowser) Kind() types.BrowserKind { return m.kind }
func TestBuildDump_Empty(t *testing.T) {
dump := BuildDump(nil)
if dump.Version != masterkey.DumpVersion {
@@ -84,9 +98,12 @@ func TestBuildDump_SingleChromium(t *testing.T) {
t.Fatalf("Vaults len = %d, want 1", len(dump.Vaults))
}
inst := dump.Vaults[0]
if inst.Browser != chromeName || inst.UserDataDir != testUDD {
if !strings.EqualFold(inst.Browser, chromeName) || inst.UserDataDir != testUDD {
t.Errorf("inst metadata = %+v", inst)
}
if inst.Kind != "chromium" {
t.Errorf("Kind = %q, want chromium", inst.Kind)
}
if len(inst.Profiles) != 1 || inst.Profiles[0] != testProfileDefault {
t.Errorf("Profiles = %v", inst.Profiles)
}
@@ -129,8 +146,8 @@ func TestBuildDump_SkipsNonKeyManager(t *testing.T) {
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (firefox skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, strings.ToLower(chromeName))
}
}
@@ -149,8 +166,8 @@ func TestBuildDump_SkipsExportError(t *testing.T) {
if len(dump.Vaults) != 1 {
t.Fatalf("Vaults len = %d, want 1 (failing browser skipped)", len(dump.Vaults))
}
if dump.Vaults[0].Browser != chromeName {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, chromeName)
if !strings.EqualFold(dump.Vaults[0].Browser, chromeName) {
t.Errorf("Browser = %q, want %q", dump.Vaults[0].Browser, strings.ToLower(chromeName))
}
}
@@ -178,6 +195,9 @@ func TestBuildDump_JSONRoundTrip(t *testing.T) {
if len(parsed.Vaults) != 1 {
t.Fatalf("Vaults len = %d", len(parsed.Vaults))
}
if parsed.Vaults[0].Kind != "chromium" {
t.Errorf("Kind round-trip: got %q, want chromium", parsed.Vaults[0].Kind)
}
if !bytes.Equal(parsed.Vaults[0].Keys.V10, dump.Vaults[0].Keys.V10) {
t.Errorf("V10 round-trip mismatch")
}
@@ -209,123 +229,168 @@ func TestBuildDump_PartialKeys(t *testing.T) {
}
}
func TestApplyDump_Match(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
func TestKindDumpRoundTrip(t *testing.T) {
for _, k := range []types.BrowserKind{types.Chromium, types.ChromiumYandex, types.ChromiumOpera} {
s, err := kindToDump(k)
if err != nil {
t.Fatalf("kindToDump(%d): %v", k, err)
}
got, err := kindFromDump(s)
if err != nil || got != k {
t.Errorf("round trip %d -> %q -> %d (err %v)", k, s, got, err)
}
}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{Browser: chromeName, UserDataDir: testUDD, Keys: masterkey.MasterKeys{V10: []byte("v10-from-dump")}},
},
if _, err := kindToDump(types.Firefox); err == nil {
t.Error("kindToDump(Firefox) should error")
}
ApplyDump([]Browser{b}, dump)
if b.receivedRetrievers.V10 == nil {
t.Fatal("V10 retriever should be set from matching vault")
}
got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if err != nil || string(got) != "v10-from-dump" {
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-from-dump")
}
if b.receivedRetrievers.V11 != nil {
t.Errorf("V11 should be nil (tier not in dump), got %v", b.receivedRetrievers.V11)
if _, err := kindFromDump("nope"); err == nil {
t.Error("kindFromDump(nope) should error")
}
}
func TestApplyDump_MissingVault(t *testing.T) {
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{Browser: testEdgeName, UserDataDir: "/edge", Keys: masterkey.MasterKeys{V10: []byte("v10")}},
},
}
ApplyDump([]Browser{b}, dump)
func TestRetrieversFromKeys(t *testing.T) {
r := retrieversFromKeys(masterkey.MasterKeys{V10: []byte("k10"), V20: []byte("k20")})
if b.receivedRetrievers.V10 != nil {
t.Errorf("V10 should remain nil when no matching vault, got %v", b.receivedRetrievers.V10)
if r.V10 == nil || r.V20 == nil {
t.Fatal("V10 and V20 retrievers should be set from non-empty keys")
}
if r.V11 != nil {
t.Error("V11 retriever should be nil when the key is absent")
}
if got, _ := r.V10.RetrieveKey(masterkey.Hints{}); string(got) != "k10" {
t.Errorf("V10 key = %q, want k10", got)
}
if got, _ := r.V20.RetrieveKey(masterkey.Hints{}); string(got) != "k20" {
t.Errorf("V20 key = %q, want k20", got)
}
}
func TestApplyDump_NonKeyManagerSkipped(t *testing.T) {
firefox := &mockBrowser{name: firefoxName, userDataDir: "/ff", profiles: []string{"default-release"}}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{Browser: firefoxName, UserDataDir: "/ff", Keys: masterkey.MasterKeys{V10: []byte("v10")}},
},
}
// firefox does not implement KeyManager; ApplyDump must not panic and must not attempt injection.
ApplyDump([]Browser{firefox}, dump)
}
func TestApplyDump_RoundTrip(t *testing.T) {
src := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
keys: masterkey.MasterKeys{V10: []byte("v10-rt"), V20: []byte("v20-rt")},
}
dump := BuildDump([]Browser{src})
dst := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: testUDD, profiles: []string{testProfileDefault}},
}
ApplyDump([]Browser{dst}, dump)
v10, _ := dst.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if string(v10) != "v10-rt" {
t.Errorf("V10 round-trip: got %q, want v10-rt", v10)
}
v20, _ := dst.receivedRetrievers.V20.RetrieveKey(masterkey.Hints{})
if string(v20) != "v20-rt" {
t.Errorf("V20 round-trip: got %q, want v20-rt", v20)
}
if dst.receivedRetrievers.V11 != nil {
t.Errorf("V11 should be nil (not in source keys), got %v", dst.receivedRetrievers.V11)
// makeUserData writes a minimal Chromium profile tree: a Preferences marker plus History (a real
// extraction source, so the profile resolves) under each named profile dir.
func makeUserData(t *testing.T, root string, profiles ...string) {
t.Helper()
for _, p := range profiles {
dir := filepath.Join(root, p)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"Preferences", "History"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
}
}
func TestApplyDump_FallbackOnPathMismatch(t *testing.T) {
// Cross-host scenario: dump was created on Windows but is applied on Linux/macOS where the
// UserDataDir literally differs. With a single vault for the browser, ApplyDump should still
// inject — otherwise the primary cross-host use case fails silently.
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{
Browser: chromeName,
UserDataDir: `C:\Users\foo\AppData\Local\Google\Chrome\User Data`,
Keys: masterkey.MasterKeys{V10: []byte("v10-fallback")},
},
},
}
ApplyDump([]Browser{b}, dump)
func TestBuildFromDump_ConventionMultiBrowser(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, filepath.Join(dataDir, "chrome"), testProfileDefault)
makeUserData(t, filepath.Join(dataDir, "edge"), testProfileDefault)
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
{Browser: "edge", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("e")}},
}}
if b.receivedRetrievers.V10 == nil {
t.Fatal("V10 retriever should be set via single-vault fallback")
browsers, err := BuildFromDump(dump, dataDir, "")
if err != nil {
t.Fatalf("BuildFromDump: %v", err)
}
got, err := b.receivedRetrievers.V10.RetrieveKey(masterkey.Hints{})
if err != nil || string(got) != "v10-fallback" {
t.Errorf("V10.RetrieveKey() = %q, err = %v, want %q", got, err, "v10-fallback")
if len(browsers) != 2 {
t.Fatalf("got %d browsers, want 2", len(browsers))
}
}
func TestApplyDump_NoFallbackWhenAmbiguous(t *testing.T) {
// Two Chrome vaults in the dump and no exact path match — ApplyDump must not guess which
// installation the local browser corresponds to.
b := &mockChromiumBrowser{
mockBrowser: mockBrowser{name: chromeName, userDataDir: "/local/chrome", profiles: []string{testProfileDefault}},
}
dump := masterkey.Dump{
Vaults: []masterkey.Vault{
{Browser: chromeName, UserDataDir: "/path/a", Keys: masterkey.MasterKeys{V10: []byte("a")}},
{Browser: chromeName, UserDataDir: "/path/b", Keys: masterkey.MasterKeys{V10: []byte("b")}},
},
}
ApplyDump([]Browser{b}, dump)
// TestBuildFromDump_ForeignKindNoPlatformTable proves restore never consults platformBrowsers():
// sogou is Windows-only yet reconstructs from its vault on any OS.
func TestBuildFromDump_ForeignKindNoPlatformTable(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, filepath.Join(dataDir, "sogou"), testProfileDefault)
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "sogou", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("k")}},
}}
if b.receivedRetrievers.V10 != nil {
t.Errorf("V10 should remain nil when fallback is ambiguous, got %v", b.receivedRetrievers.V10)
browsers, err := BuildFromDump(dump, dataDir, "")
if err != nil {
t.Fatalf("BuildFromDump: %v", err)
}
if len(browsers) != 1 {
t.Fatalf("got %d browsers, want 1", len(browsers))
}
if browsers[0].BrowserName() != "sogou" {
t.Errorf("BrowserName = %q, want sogou", browsers[0].BrowserName())
}
}
func TestBuildFromDump_RawSingleBrowser(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, dataDir, testProfileDefault)
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
}}
browsers, err := BuildFromDump(dump, dataDir, "chrome")
if err != nil {
t.Fatalf("BuildFromDump: %v", err)
}
if len(browsers) != 1 {
t.Fatalf("got %d browsers, want 1", len(browsers))
}
if browsers[0].UserDataDir() != dataDir {
t.Errorf("UserDataDir = %q, want %q (raw root)", browsers[0].UserDataDir(), dataDir)
}
}
func TestBuildFromDump_UnknownBrowserErrors(t *testing.T) {
dataDir := t.TempDir()
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
}}
if _, err := BuildFromDump(dump, dataDir, "sogou"); err == nil {
t.Fatal("expected error for -b matching no vault")
}
}
func TestBuildFromDump_RawAmbiguousErrors(t *testing.T) {
dataDir := t.TempDir()
makeUserData(t, dataDir, testProfileDefault)
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
{Browser: "edge", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("e")}},
}}
if _, err := BuildFromDump(dump, dataDir, ""); err == nil {
t.Fatal("expected ambiguity error for raw multi-vault restore without -b")
}
}
func TestBuildFromDump_MissingDataDirErrors(t *testing.T) {
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
}}
if _, err := BuildFromDump(dump, "/no/such/dir", ""); err == nil {
t.Fatal("expected error when data dir does not exist")
}
}
// TestBuildFromDump_MarkerlessTreeStillResolves covers an archive/copy that omitted Preferences:
// the source-bearing-subdir fallback in discoverProfiles must still find the profile.
func TestBuildFromDump_MarkerlessTreeStillResolves(t *testing.T) {
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "chrome", testProfileDefault)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "History"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
dump := masterkey.Dump{Vaults: []masterkey.Vault{
{Browser: "chrome", Kind: "chromium", Keys: masterkey.MasterKeys{V10: []byte("c")}},
}}
browsers, err := BuildFromDump(dump, dataDir, "")
if err != nil {
t.Fatalf("BuildFromDump: %v", err)
}
if len(browsers) != 1 {
t.Fatalf("got %d browsers, want 1 (marker-less profile must resolve via source fallback)", len(browsers))
}
}