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:
Roger
2026-06-07 15:58:33 +08:00
committed by GitHub
parent f1219e49ab
commit cd0b2daaf3
11 changed files with 543 additions and 15 deletions
+64
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+5 -2
View File
@@ -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
}
}
+4 -4
View File
@@ -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;