From 8851ef63ba41b7bc6d49f89f529ee0455a780f82 Mon Sep 17 00:00:00 2001 From: moonD4rk Date: Sat, 6 Jun 2026 21:04:24 +0800 Subject: [PATCH] fix(archive): correct flat-layout path and entry-count wording Drop the phantom / level for flat-layout installs (profileDir == root), say "entries" not "files" (count is per source path), and add ZipDir/Unzip (>1MB, empty, Zip-Slip) + flat-layout tests. --- browser/archive.go | 2 +- browser/chromium/archive.go | 7 ++- browser/chromium/archive_test.go | 30 ++++++++++ cmd/hack-browser-data/archive.go | 2 +- utils/fileutil/fileutil_zip_test.go | 88 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 utils/fileutil/fileutil_zip_test.go diff --git a/browser/archive.go b/browser/archive.go index eb5ba90..24e8c8e 100644 --- a/browser/archive.go +++ b/browser/archive.go @@ -22,7 +22,7 @@ type Archivable interface { // BuildArchive packs each browser's decryption-relevant files into a zip whose internal layout is // /, 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 files captured. +// the number of source entries staged (a directory source counts once). func BuildArchive(browsers []Browser, categories []types.Category, outPath string) (int, error) { session, err := filemanager.NewSession() if err != nil { diff --git a/browser/chromium/archive.go b/browser/chromium/archive.go index e6b09bf..ae1f93c 100644 --- a/browser/chromium/archive.go +++ b/browser/chromium/archive.go @@ -32,7 +32,12 @@ func (b *Browser) ArchiveSources(categories []types.Category) []ArchiveSource { } } for _, p := range b.profiles { - profileRel := filepath.Base(p.profileDir) + // 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) { diff --git a/browser/chromium/archive_test.go b/browser/chromium/archive_test.go index 9140a1c..ecc13c7 100644 --- a/browser/chromium/archive_test.go +++ b/browser/chromium/archive_test.go @@ -56,3 +56,33 @@ func TestArchiveSources_ForwardSlashLayout(t *testing.T) { 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) + } +} diff --git a/cmd/hack-browser-data/archive.go b/cmd/hack-browser-data/archive.go index 0727da1..30b583d 100644 --- a/cmd/hack-browser-data/archive.go +++ b/cmd/hack-browser-data/archive.go @@ -36,7 +36,7 @@ func archiveCmd() *cobra.Command { if err != nil { return err } - log.Infof("Archived %d file(s) to %s", n, outputPath) + log.Infof("Archived %d entries to %s", n, outputPath) return nil }, } diff --git a/utils/fileutil/fileutil_zip_test.go b/utils/fileutil/fileutil_zip_test.go new file mode 100644 index 0000000..9d144af --- /dev/null +++ b/utils/fileutil/fileutil_zip_test.go @@ -0,0 +1,88 @@ +package fileutil + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestZipDirUnzip_RoundTrip(t *testing.T) { + src := t.TempDir() + files := map[string][]byte{ + "empty.txt": {}, + "small.txt": []byte("hello"), + "Default/Network/Cookies": []byte("cookie-bytes"), + "sub/big.bin": bytes.Repeat([]byte("A"), 3<<20), // 3 MiB: exercises the chunked copy loop + } + for rel, data := range files { + p := filepath.Join(src, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatal(err) + } + } + + zipPath := filepath.Join(t.TempDir(), "out.zip") + if err := ZipDir(zipPath, src); err != nil { + t.Fatalf("ZipDir: %v", err) + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + for _, f := range zr.File { + if strings.Contains(f.Name, `\`) { + t.Errorf("zip entry name must be forward-slash, got %q", f.Name) + } + } + if err := zr.Close(); err != nil { + t.Fatal(err) + } + + dst := t.TempDir() + if err := Unzip(zipPath, dst); err != nil { + t.Fatalf("Unzip: %v", err) + } + for rel, want := range files { + got, err := os.ReadFile(filepath.Join(dst, filepath.FromSlash(rel))) + if err != nil { + t.Errorf("missing %s after Unzip: %v", rel, err) + continue + } + if !bytes.Equal(got, want) { + t.Errorf("%s: content mismatch (got %d bytes, want %d)", rel, len(got), len(want)) + } + } +} + +func TestUnzip_RejectsZipSlip(t *testing.T) { + zipPath := filepath.Join(t.TempDir(), "evil.zip") + f, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + w, err := zw.Create("../escape.txt") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("pwned")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + if err := Unzip(zipPath, t.TempDir()); err == nil { + t.Fatal("Unzip must reject an entry that escapes the destination") + } +}