mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat: add output package with Formatter interface (#537)
* docs: add RFC-004 for CLI (cobra) and output design * feat: add output package with Formatter interface and BrowserData.Each * fix: golangci config array syntax + add output package tests * refactor: encapsulated Output as Writer, collect-then-write pattern * refactor: unified row type with reflection-based CSV/JSON output * fix: ProfileName empty guard, writeFile close error check, sync RFC-004
This commit is contained in:
@@ -69,6 +69,9 @@ linters:
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 3
|
||||
ignore-string-values:
|
||||
- "csv"
|
||||
- "json"
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
// Browser represents a single Chromium profile ready for extraction.
|
||||
type Browser struct {
|
||||
cfg types.BrowserConfig
|
||||
name string // display name: "Chrome-Default"
|
||||
profileDir string // absolute path to profile directory
|
||||
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
||||
extractors map[types.Category]categoryExtractor // Category → custom extract function override
|
||||
@@ -41,7 +40,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||
}
|
||||
browsers = append(browsers, &Browser{
|
||||
cfg: cfg,
|
||||
name: cfg.Name + "-" + filepath.Base(profileDir),
|
||||
profileDir: profileDir,
|
||||
sources: sources,
|
||||
extractors: extractors,
|
||||
@@ -51,8 +49,12 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||
return browsers, nil
|
||||
}
|
||||
|
||||
func (b *Browser) Name() string {
|
||||
return b.name
|
||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||
func (b *Browser) ProfileName() string {
|
||||
if b.profileDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(b.profileDir)
|
||||
}
|
||||
|
||||
// Extract copies browser files to a temp directory, retrieves the master key,
|
||||
@@ -68,7 +70,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
|
||||
|
||||
masterKey, err := b.getMasterKey(session)
|
||||
if err != nil {
|
||||
log.Debugf("get master key for %s: %v", b.name, err)
|
||||
log.Debugf("get master key for %s: %v", b.BrowserName()+"/"+b.ProfileName(), err)
|
||||
}
|
||||
|
||||
data := &types.BrowserData{}
|
||||
@@ -134,7 +136,7 @@ func (b *Browser) getMasterKey(session *filemanager.Session) ([]byte, error) {
|
||||
func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, masterKey []byte, path string) {
|
||||
if ext, ok := b.extractors[cat]; ok {
|
||||
if err := ext.extract(masterKey, path, data); err != nil {
|
||||
log.Debugf("extract %s for %s: %v", cat, b.name, err)
|
||||
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -161,7 +163,7 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m
|
||||
data.SessionStorage, err = extractSessionStorage(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("extract %s for %s: %v", cat, b.name, err)
|
||||
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,7 +369,6 @@ func TestExtractCategory_DefaultFallback(t *testing.T) {
|
||||
)
|
||||
|
||||
b := &Browser{
|
||||
name: "Test",
|
||||
extractors: nil, // no custom extractors
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
// Browser represents a single Firefox profile ready for extraction.
|
||||
type Browser struct {
|
||||
cfg types.BrowserConfig
|
||||
name string // display name: "Firefox-97nszz88.default-release"
|
||||
profileDir string // absolute path to profile directory
|
||||
sources map[types.Category][]sourcePath // Category → candidate paths (priority order)
|
||||
sourcePaths map[types.Category]resolvedPath // Category → discovered absolute path
|
||||
@@ -39,7 +38,6 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||
}
|
||||
browsers = append(browsers, &Browser{
|
||||
cfg: cfg,
|
||||
name: cfg.Name + "-" + filepath.Base(profileDir),
|
||||
profileDir: profileDir,
|
||||
sources: firefoxSources,
|
||||
sourcePaths: sourcePaths,
|
||||
@@ -48,8 +46,12 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
|
||||
return browsers, nil
|
||||
}
|
||||
|
||||
func (b *Browser) Name() string {
|
||||
return b.name
|
||||
func (b *Browser) BrowserName() string { return b.cfg.Name }
|
||||
func (b *Browser) ProfileName() string {
|
||||
if b.profileDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(b.profileDir)
|
||||
}
|
||||
|
||||
// Extract copies browser files to a temp directory, retrieves the master key,
|
||||
@@ -65,7 +67,7 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
|
||||
|
||||
masterKey, err := b.getMasterKey(session, tempPaths)
|
||||
if err != nil {
|
||||
log.Debugf("get master key for %s: %v", b.name, err)
|
||||
log.Debugf("get master key for %s: %v", b.BrowserName()+"/"+b.ProfileName(), err)
|
||||
}
|
||||
|
||||
data := &types.BrowserData{}
|
||||
@@ -169,7 +171,7 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m
|
||||
// Firefox does not support CreditCard or SessionStorage extraction.
|
||||
}
|
||||
if err != nil {
|
||||
log.Debugf("extract %s for %s: %v", cat, b.name, err)
|
||||
log.Debugf("extract %s for %s: %v", cat, b.BrowserName()+"/"+b.ProfileName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ func TestExtractCategory(t *testing.T) {
|
||||
insertMozPlace(1, "https://example.com", "Example", 3, 1000000),
|
||||
insertMozPlace(2, "https://go.dev", "Go", 1, 2000000),
|
||||
)
|
||||
b := &Browser{name: "Test"}
|
||||
b := &Browser{}
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.History, nil, path)
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestExtractCategory(t *testing.T) {
|
||||
[]string{mozCookiesSchema},
|
||||
insertMozCookie("session", "abc", ".example.com", "/", 1000000000000, 0, 0, 0),
|
||||
)
|
||||
b := &Browser{name: "Test"}
|
||||
b := &Browser{}
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.Cookie, nil, path)
|
||||
|
||||
@@ -214,7 +214,7 @@ func TestExtractCategory(t *testing.T) {
|
||||
insertMozPlace(1, "https://github.com", "GitHub", 1, 1000000),
|
||||
insertMozBookmark(1, 1, 1, "GitHub", 1000000),
|
||||
)
|
||||
b := &Browser{name: "Test"}
|
||||
b := &Browser{}
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.Bookmark, nil, path)
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestExtractCategory(t *testing.T) {
|
||||
}
|
||||
]
|
||||
}`)
|
||||
b := &Browser{name: "Test"}
|
||||
b := &Browser{}
|
||||
data := &types.BrowserData{}
|
||||
b.extractCategory(data, types.Extension, nil, path)
|
||||
|
||||
@@ -248,7 +248,7 @@ func TestExtractCategory(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("UnsupportedCategory", func(t *testing.T) {
|
||||
b := &Browser{name: "Test"}
|
||||
b := &Browser{}
|
||||
data := &types.BrowserData{}
|
||||
// CreditCard and SessionStorage are not supported by Firefox
|
||||
b.extractCategory(data, types.CreditCard, nil, "unused")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
type cookieEditorFormatter struct{}
|
||||
|
||||
func (f *cookieEditorFormatter) ext() string { return "json" }
|
||||
|
||||
func (f *cookieEditorFormatter) format(w io.Writer, rows []row) error {
|
||||
entries := make([]cookieEditorEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
c, ok := r.entry.(types.CookieEntry)
|
||||
if !ok {
|
||||
return nil // not cookies, skip
|
||||
}
|
||||
var expDate float64
|
||||
if !c.ExpireAt.IsZero() {
|
||||
expDate = float64(c.ExpireAt.Unix())
|
||||
}
|
||||
entries = append(entries, cookieEditorEntry{
|
||||
Domain: c.Host,
|
||||
ExpirationDate: expDate,
|
||||
HTTPOnly: c.IsHTTPOnly,
|
||||
Name: c.Name,
|
||||
Path: c.Path,
|
||||
Secure: c.IsSecure,
|
||||
Value: c.Value,
|
||||
})
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(entries)
|
||||
}
|
||||
|
||||
// cookieEditorEntry matches the CookieEditor browser extension's import format.
|
||||
type cookieEditorEntry struct {
|
||||
Domain string `json:"domain"`
|
||||
ExpirationDate float64 `json:"expirationDate"`
|
||||
HTTPOnly bool `json:"httpOnly"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Secure bool `json:"secure"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
)
|
||||
|
||||
type csvFormatter struct{}
|
||||
|
||||
func (f *csvFormatter) ext() string { return "csv" }
|
||||
|
||||
func (f *csvFormatter) format(w io.Writer, rows []row) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
if err := cw.Write(rows[0].csvHeader()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := cw.Write(r.csvRow()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// formatter serializes rows to a writer. Unexported — only used by Writer.
|
||||
type formatter interface {
|
||||
format(w io.Writer, rows []row) error
|
||||
ext() string
|
||||
}
|
||||
|
||||
func newFormatter(name string) (formatter, error) {
|
||||
switch name {
|
||||
case "csv":
|
||||
return &csvFormatter{}, nil
|
||||
case "json":
|
||||
return &jsonFormatter{}, nil
|
||||
case "cookie-editor":
|
||||
return &cookieEditorFormatter{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
type jsonFormatter struct{}
|
||||
|
||||
func (f *jsonFormatter) ext() string { return "json" }
|
||||
|
||||
func (f *jsonFormatter) format(w io.Writer, rows []row) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
return enc.Encode(rows)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package output writes extracted browser data to files.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// w, _ := output.NewWriter(dir, "csv")
|
||||
// w.Add(browserName, profileName, data)
|
||||
// w.Write()
|
||||
//
|
||||
// Supported formats: csv, json, cookie-editor.
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/log"
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
// utf8BOM is written at the start of CSV files for Excel compatibility.
|
||||
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
|
||||
|
||||
// Writer collects browser data and writes it to files.
|
||||
// It is the only exported type in this package.
|
||||
type Writer struct {
|
||||
dir string
|
||||
formatter formatter
|
||||
results []result
|
||||
}
|
||||
|
||||
type result struct {
|
||||
browser string
|
||||
profile string
|
||||
data *types.BrowserData
|
||||
}
|
||||
|
||||
// NewWriter creates a Writer that writes to dir in the given format.
|
||||
func NewWriter(dir, format string) (*Writer, error) {
|
||||
f, err := newFormatter(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Writer{dir: dir, formatter: f}, nil
|
||||
}
|
||||
|
||||
// Add accumulates one browser profile's data for later writing.
|
||||
func (o *Writer) Add(browser, profile string, data *types.BrowserData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
o.results = append(o.results, result{browser, profile, data})
|
||||
}
|
||||
|
||||
// Write aggregates all accumulated data by category and writes each
|
||||
// non-empty category to its own file (e.g. password.csv, cookie.json).
|
||||
func (o *Writer) Write() error {
|
||||
if err := os.MkdirAll(o.dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
for _, cs := range o.aggregate() {
|
||||
if err := o.writeFile(cs.name, cs.rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// categoryRows holds one category's aggregated rows for writing.
|
||||
type categoryRows struct {
|
||||
name string
|
||||
rows []row
|
||||
}
|
||||
|
||||
// extractor pulls rows from a single result for one category.
|
||||
type extractor func(r result) []row
|
||||
|
||||
// makeExtractor creates a type-safe extractor using generics.
|
||||
func makeExtractor[T any](entries func(*types.BrowserData) []T) extractor {
|
||||
return func(r result) []row {
|
||||
items := entries(r.data)
|
||||
rows := make([]row, 0, len(items))
|
||||
for _, e := range items {
|
||||
rows = append(rows, row{Browser: r.browser, Profile: r.profile, entry: e})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
// categories maps each data category to its extractor.
|
||||
// Adding a new category requires only one line here.
|
||||
var categories = []struct {
|
||||
name string
|
||||
extract extractor
|
||||
}{
|
||||
{"password", makeExtractor(func(d *types.BrowserData) []types.LoginEntry { return d.Passwords })},
|
||||
{"cookie", makeExtractor(func(d *types.BrowserData) []types.CookieEntry { return d.Cookies })},
|
||||
{"history", makeExtractor(func(d *types.BrowserData) []types.HistoryEntry { return d.Histories })},
|
||||
{"download", makeExtractor(func(d *types.BrowserData) []types.DownloadEntry { return d.Downloads })},
|
||||
{"bookmark", makeExtractor(func(d *types.BrowserData) []types.BookmarkEntry { return d.Bookmarks })},
|
||||
{"creditcard", makeExtractor(func(d *types.BrowserData) []types.CreditCardEntry { return d.CreditCards })},
|
||||
{"extension", makeExtractor(func(d *types.BrowserData) []types.ExtensionEntry { return d.Extensions })},
|
||||
{"localstorage", makeExtractor(func(d *types.BrowserData) []types.StorageEntry { return d.LocalStorage })},
|
||||
{"sessionstorage", makeExtractor(func(d *types.BrowserData) []types.StorageEntry { return d.SessionStorage })},
|
||||
}
|
||||
|
||||
// aggregate merges all results into row slices grouped by category,
|
||||
// returning only non-empty categories.
|
||||
func (o *Writer) aggregate() []categoryRows {
|
||||
var s []categoryRows
|
||||
for _, cat := range categories {
|
||||
var rows []row
|
||||
for _, r := range o.results {
|
||||
rows = append(rows, cat.extract(r)...)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
s = append(s, categoryRows{cat.name, rows})
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (o *Writer) writeFile(category string, rows []row) (err error) {
|
||||
// Format to buffer first — if formatter produces no output (e.g.
|
||||
// cookie-editor skipping non-cookie data), don't create the file.
|
||||
var buf bytes.Buffer
|
||||
if err := o.formatter.format(&buf, rows); err != nil {
|
||||
return fmt.Errorf("format %s: %w", category, err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.%s", category, o.formatter.ext())
|
||||
path := filepath.Join(o.dir, filename)
|
||||
|
||||
f, err := os.OpenFile(filepath.Clean(path), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", filename, err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := f.Close(); cerr != nil && err == nil {
|
||||
err = fmt.Errorf("close %s: %w", filename, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if strings.HasSuffix(path, ".csv") {
|
||||
if _, err := f.Write(utf8BOM); err != nil {
|
||||
return fmt.Errorf("write BOM: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := f.Write(buf.Bytes()); err != nil {
|
||||
return fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
log.Warnf("export: %s", path)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
var testTime = time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
func chromeData() *types.BrowserData {
|
||||
return &types.BrowserData{
|
||||
Passwords: []types.LoginEntry{
|
||||
{URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: testTime},
|
||||
},
|
||||
Cookies: []types.CookieEntry{
|
||||
{
|
||||
Host: ".example.com", Path: "/", Name: "session", Value: "abc123",
|
||||
IsSecure: true, IsHTTPOnly: true, HasExpire: true, IsPersistent: true,
|
||||
ExpireAt: testTime, CreatedAt: testTime,
|
||||
},
|
||||
},
|
||||
Histories: []types.HistoryEntry{
|
||||
{URL: "https://example.com", Title: "Example", VisitCount: 5, LastVisit: testTime},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firefoxData() *types.BrowserData {
|
||||
return &types.BrowserData{
|
||||
Passwords: []types.LoginEntry{
|
||||
{URL: "https://reddit.com", Username: "bob", Password: "hunter2", CreatedAt: testTime},
|
||||
},
|
||||
Cookies: []types.CookieEntry{
|
||||
{
|
||||
Host: ".reddit.com", Path: "/", Name: "token", Value: "xyz789",
|
||||
IsSecure: true, IsHTTPOnly: false, ExpireAt: testTime, CreatedAt: testTime,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- New ---
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{"csv", false},
|
||||
{"json", false},
|
||||
{"cookie-editor", false},
|
||||
{"unknown", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.format, func(t *testing.T) {
|
||||
out, err := NewWriter(t.TempDir(), tt.format)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, out)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSV output ---
|
||||
|
||||
func TestWrite_CSV_Password(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
out.Add("Firefox", "abc123", firefoxData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
records := readCSV(t, filepath.Join(dir, "password.csv"))
|
||||
require.Len(t, records, 3) // header + 2 rows
|
||||
|
||||
assert.Equal(t, []string{"browser", "profile", "url", "username", "password", "created_at"}, records[0])
|
||||
assert.Equal(t, []string{"Chrome", "Default", "https://example.com", "alice", "secret", "2026-01-15T10:30:00Z"}, records[1])
|
||||
assert.Equal(t, []string{"Firefox", "abc123", "https://reddit.com", "bob", "hunter2", "2026-01-15T10:30:00Z"}, records[2])
|
||||
}
|
||||
|
||||
func TestWrite_CSV_Cookie(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
records := readCSV(t, filepath.Join(dir, "cookie.csv"))
|
||||
require.Len(t, records, 2)
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{
|
||||
"browser", "profile", "host", "path", "name", "value",
|
||||
"is_secure", "is_http_only", "has_expire", "is_persistent", "expire_at", "created_at",
|
||||
},
|
||||
records[0],
|
||||
)
|
||||
assert.Equal(t,
|
||||
[]string{
|
||||
"Chrome", "Default", ".example.com", "/", "session", "abc123",
|
||||
"true", "true", "true", "true", "2026-01-15T10:30:00Z", "2026-01-15T10:30:00Z",
|
||||
},
|
||||
records[1],
|
||||
)
|
||||
}
|
||||
|
||||
func TestWrite_CSV_History(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Profile 1", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
records := readCSV(t, filepath.Join(dir, "history.csv"))
|
||||
require.Len(t, records, 2)
|
||||
|
||||
assert.Equal(t, []string{"browser", "profile", "url", "title", "visit_count", "last_visit"}, records[0])
|
||||
assert.Equal(t, []string{"Chrome", "Profile 1", "https://example.com", "Example", "5", "2026-01-15T10:30:00Z"}, records[1])
|
||||
}
|
||||
|
||||
func TestWrite_CSV_UTF8BOM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "password.csv"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(raw) >= 3)
|
||||
assert.Equal(t, utf8BOM, raw[:3], "CSV should start with UTF-8 BOM")
|
||||
}
|
||||
|
||||
// --- JSON output ---
|
||||
|
||||
func TestWrite_JSON_Password(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "json")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
out.Add("Firefox", "abc123", firefoxData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
type pwJSON struct {
|
||||
Browser string `json:"browser"`
|
||||
Profile string `json:"profile"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
var rows []pwJSON
|
||||
readJSON(t, filepath.Join(dir, "password.json"), &rows)
|
||||
require.Len(t, rows, 2)
|
||||
|
||||
assert.Equal(t, pwJSON{
|
||||
Browser: "Chrome", Profile: "Default",
|
||||
URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: testTime,
|
||||
}, rows[0])
|
||||
assert.Equal(t, pwJSON{
|
||||
Browser: "Firefox", Profile: "abc123",
|
||||
URL: "https://reddit.com", Username: "bob", Password: "hunter2", CreatedAt: testTime,
|
||||
}, rows[1])
|
||||
}
|
||||
|
||||
func TestWrite_JSON_Cookie(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "json")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
type ckJSON struct {
|
||||
Browser string `json:"browser"`
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
IsSecure bool `json:"is_secure"`
|
||||
IsHTTPOnly bool `json:"is_http_only"`
|
||||
HasExpire bool `json:"has_expire"`
|
||||
IsPersistent bool `json:"is_persistent"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
var rows []ckJSON
|
||||
readJSON(t, filepath.Join(dir, "cookie.json"), &rows)
|
||||
require.Len(t, rows, 1)
|
||||
|
||||
assert.Equal(t, ckJSON{
|
||||
Browser: "Chrome", Profile: "Default",
|
||||
Host: ".example.com", Path: "/", Name: "session", Value: "abc123",
|
||||
IsSecure: true, IsHTTPOnly: true, HasExpire: true, IsPersistent: true,
|
||||
ExpireAt: testTime, CreatedAt: testTime,
|
||||
}, rows[0])
|
||||
}
|
||||
|
||||
func TestWrite_JSON_NoBOM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "json")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "password.json"))
|
||||
require.NoError(t, err)
|
||||
if len(raw) >= 3 {
|
||||
assert.NotEqual(t, utf8BOM, raw[:3], "JSON should NOT have BOM")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CookieEditor output ---
|
||||
|
||||
func TestWrite_CookieEditor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "cookie-editor")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", chromeData())
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
var entries []cookieEditorEntry
|
||||
readJSON(t, filepath.Join(dir, "cookie.json"), &entries)
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
assert.Equal(t, cookieEditorEntry{
|
||||
Domain: ".example.com",
|
||||
Name: "session",
|
||||
Value: "abc123",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
ExpirationDate: float64(testTime.Unix()),
|
||||
}, entries[0])
|
||||
}
|
||||
|
||||
func TestWrite_CookieEditor_SkipsNonCookie(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "cookie-editor")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", &types.BrowserData{
|
||||
Passwords: []types.LoginEntry{{URL: "https://a.com"}},
|
||||
})
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
// password file should not be created (cookie-editor only exports cookies)
|
||||
_, err = os.Stat(filepath.Join(dir, "password.json"))
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
// --- File creation ---
|
||||
|
||||
func TestWrite_EmptyCategoryNoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
out.Add("Chrome", "Default", &types.BrowserData{
|
||||
Passwords: []types.LoginEntry{{URL: "https://a.com"}},
|
||||
})
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
assert.FileExists(t, filepath.Join(dir, "password.csv"))
|
||||
_, err = os.Stat(filepath.Join(dir, "cookie.csv"))
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
_, err = os.Stat(filepath.Join(dir, "history.csv"))
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestWrite_NoData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out, err := NewWriter(dir, "csv")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, out.Write())
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
assert.Empty(t, entries, "no files should be created when no data added")
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func readCSV(t *testing.T, path string) [][]string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
// Skip UTF-8 BOM if present
|
||||
content := string(raw)
|
||||
if strings.HasPrefix(content, string(utf8BOM)) {
|
||||
content = content[len(utf8BOM):]
|
||||
}
|
||||
reader := csv.NewReader(strings.NewReader(content))
|
||||
records, err := reader.ReadAll()
|
||||
require.NoError(t, err)
|
||||
return records
|
||||
}
|
||||
|
||||
func readJSON(t *testing.T, path string, v any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(raw, v))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var timeType = reflect.TypeOf(time.Time{})
|
||||
|
||||
// structCSVHeader extracts CSV column names from a struct's csv tags.
|
||||
func structCSVHeader(v any) []string {
|
||||
t := reflect.TypeOf(v)
|
||||
headers := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
name := tagName(t.Field(i), "csv")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, name)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// structCSVRow converts a struct's field values to CSV string values,
|
||||
// including only fields that have a csv tag.
|
||||
func structCSVRow(v any) []string {
|
||||
val := reflect.ValueOf(v)
|
||||
t := val.Type()
|
||||
row := make([]string, 0, t.NumField())
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if tagName(t.Field(i), "csv") == "" {
|
||||
continue
|
||||
}
|
||||
row = append(row, fieldToString(val.Field(i)))
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// tagName extracts the tag value for the given key from a struct field.
|
||||
// Uses Lookup (not Get) to distinguish "no tag" from "empty tag".
|
||||
// Returns "" if the tag is absent, empty, or "-".
|
||||
func tagName(f reflect.StructField, key string) string {
|
||||
tag, ok := f.Tag.Lookup(key)
|
||||
if !ok || tag == "-" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.IndexByte(tag, ','); idx != -1 {
|
||||
tag = tag[:idx]
|
||||
}
|
||||
if tag == "" {
|
||||
return ""
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func fieldToString(v reflect.Value) string {
|
||||
// Check time.Time before kind switch since it's a struct.
|
||||
if v.Type() == timeType {
|
||||
t, _ := v.Interface().(time.Time)
|
||||
return formatTime(t)
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String()
|
||||
case reflect.Bool:
|
||||
return formatBool(v.Bool())
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(v.Int(), 10)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func formatBool(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/moond4rk/hackbrowserdata/types"
|
||||
)
|
||||
|
||||
var refTime = time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
// allEntryTypes lists every entry type that appears in BrowserData.
|
||||
// If a new entry type is added, it must be included here.
|
||||
var allEntryTypes = []any{
|
||||
types.LoginEntry{},
|
||||
types.CookieEntry{},
|
||||
types.BookmarkEntry{},
|
||||
types.HistoryEntry{},
|
||||
types.DownloadEntry{},
|
||||
types.CreditCardEntry{},
|
||||
types.StorageEntry{},
|
||||
types.ExtensionEntry{},
|
||||
}
|
||||
|
||||
// TestAllEntryFieldsHaveCSVTag verifies that every exported field
|
||||
// in every entry type has a csv tag. A missing tag means the field
|
||||
// will be silently omitted from CSV output.
|
||||
func TestAllEntryFieldsHaveCSVTag(t *testing.T) {
|
||||
for _, entry := range allEntryTypes {
|
||||
et := reflect.TypeOf(entry)
|
||||
t.Run(et.Name(), func(t *testing.T) {
|
||||
for i := 0; i < et.NumField(); i++ {
|
||||
f := et.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
_, ok := f.Tag.Lookup("csv")
|
||||
assert.True(t, ok, "field %s.%s missing csv tag", et.Name(), f.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructCSVHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry any
|
||||
expect []string
|
||||
}{
|
||||
{"LoginEntry", types.LoginEntry{}, []string{"url", "username", "password", "created_at"}},
|
||||
{"CookieEntry", types.CookieEntry{}, []string{"host", "path", "name", "value", "is_secure", "is_http_only", "has_expire", "is_persistent", "expire_at", "created_at"}},
|
||||
{"BookmarkEntry", types.BookmarkEntry{}, []string{"name", "url", "folder", "created_at"}},
|
||||
{"HistoryEntry", types.HistoryEntry{}, []string{"url", "title", "visit_count", "last_visit"}},
|
||||
{"DownloadEntry", types.DownloadEntry{}, []string{"url", "target_path", "mime_type", "total_bytes", "start_time", "end_time"}},
|
||||
{"CreditCardEntry", types.CreditCardEntry{}, []string{"name", "number", "exp_month", "exp_year", "nick_name", "address"}},
|
||||
{"StorageEntry", types.StorageEntry{}, []string{"url", "key", "value"}},
|
||||
{"ExtensionEntry", types.ExtensionEntry{}, []string{"name", "id", "description", "version", "homepage_url", "enabled"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expect, structCSVHeader(tt.entry))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructCSVRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry any
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
"LoginEntry",
|
||||
types.LoginEntry{URL: "https://example.com", Username: "alice", Password: "secret", CreatedAt: refTime},
|
||||
[]string{"https://example.com", "alice", "secret", "2026-01-15T10:30:00Z"},
|
||||
},
|
||||
{
|
||||
"CookieEntry",
|
||||
types.CookieEntry{
|
||||
Host: ".example.com", Path: "/", Name: "session", Value: "abc",
|
||||
IsSecure: true, IsHTTPOnly: true, HasExpire: true, IsPersistent: false,
|
||||
ExpireAt: refTime, CreatedAt: refTime,
|
||||
},
|
||||
[]string{".example.com", "/", "session", "abc", "true", "true", "true", "false", "2026-01-15T10:30:00Z", "2026-01-15T10:30:00Z"},
|
||||
},
|
||||
{
|
||||
"HistoryEntry_int",
|
||||
types.HistoryEntry{URL: "https://a.com", Title: "A", VisitCount: 42, LastVisit: refTime},
|
||||
[]string{"https://a.com", "A", "42", "2026-01-15T10:30:00Z"},
|
||||
},
|
||||
{
|
||||
"DownloadEntry_int64",
|
||||
types.DownloadEntry{URL: "https://a.com", TargetPath: "/tmp/f", MimeType: "text/plain", TotalBytes: 1024, StartTime: refTime, EndTime: refTime},
|
||||
[]string{"https://a.com", "/tmp/f", "text/plain", "1024", "2026-01-15T10:30:00Z", "2026-01-15T10:30:00Z"},
|
||||
},
|
||||
{
|
||||
"ExtensionEntry_bool",
|
||||
types.ExtensionEntry{Name: "ext", ID: "abc", Description: "desc", Version: "1.0", HomepageURL: "https://x.com", Enabled: true},
|
||||
[]string{"ext", "abc", "desc", "1.0", "https://x.com", "true"},
|
||||
},
|
||||
{
|
||||
"zero_time",
|
||||
types.LoginEntry{URL: "https://a.com"},
|
||||
[]string{"https://a.com", "", "", ""},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expect, structCSVRow(tt.entry))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowMarshalJSON verifies that row.MarshalJSON produces flat JSON
|
||||
// with browser/profile first, followed by entry fields in declaration order.
|
||||
func TestRowMarshalJSON(t *testing.T) {
|
||||
t.Run("flat_structure", func(t *testing.T) {
|
||||
r := row{
|
||||
Browser: "Chrome",
|
||||
Profile: "Default",
|
||||
entry: types.LoginEntry{
|
||||
URL: "https://example.com", Username: "alice",
|
||||
Password: "secret", CreatedAt: refTime,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify flat JSON (all keys at top level, no nesting).
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &m))
|
||||
assert.Equal(t, "Chrome", m["browser"])
|
||||
assert.Equal(t, "Default", m["profile"])
|
||||
assert.Equal(t, "https://example.com", m["url"])
|
||||
assert.Equal(t, "alice", m["username"])
|
||||
assert.Equal(t, "secret", m["password"])
|
||||
assert.Len(t, m, 6) // browser + profile + 4 entry fields
|
||||
|
||||
// Verify field order: browser, profile come before entry fields.
|
||||
raw := string(data)
|
||||
browserIdx := strings.Index(raw, `"browser"`)
|
||||
profileIdx := strings.Index(raw, `"profile"`)
|
||||
urlIdx := strings.Index(raw, `"url"`)
|
||||
assert.Less(t, browserIdx, urlIdx)
|
||||
assert.Less(t, profileIdx, urlIdx)
|
||||
})
|
||||
|
||||
t.Run("bool_and_time_fields", func(t *testing.T) {
|
||||
r := row{
|
||||
Browser: "Firefox",
|
||||
Profile: "test",
|
||||
entry: types.CookieEntry{
|
||||
Host: ".example.com", IsSecure: true, IsHTTPOnly: false,
|
||||
ExpireAt: refTime,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &m))
|
||||
assert.Equal(t, "Firefox", m["browser"])
|
||||
assert.Equal(t, ".example.com", m["host"])
|
||||
assert.Equal(t, true, m["is_secure"])
|
||||
assert.Equal(t, false, m["is_http_only"])
|
||||
})
|
||||
|
||||
t.Run("special_characters", func(t *testing.T) {
|
||||
r := row{
|
||||
Browser: `Ch"rome`,
|
||||
Profile: "Default",
|
||||
entry: types.LoginEntry{
|
||||
URL: `https://example.com/path?q="hello"&x=1`,
|
||||
Password: `pass"word\with<special>`,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(data, &m))
|
||||
assert.Equal(t, `Ch"rome`, m["browser"])
|
||||
assert.Equal(t, `https://example.com/path?q="hello"&x=1`, m["url"])
|
||||
assert.Equal(t, `pass"word\with<special>`, m["password"])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// row wraps any entry with browser/profile context for output.
|
||||
type row struct {
|
||||
Browser string
|
||||
Profile string
|
||||
entry any
|
||||
}
|
||||
|
||||
func (r row) csvHeader() []string {
|
||||
return append([]string{"browser", "profile"}, structCSVHeader(r.entry)...)
|
||||
}
|
||||
|
||||
func (r row) csvRow() []string {
|
||||
return append([]string{r.Browser, r.Profile}, structCSVRow(r.entry)...)
|
||||
}
|
||||
|
||||
// MarshalJSON produces flat JSON with browser/profile followed by the entry's fields.
|
||||
// Uses reflect.StructOf to dynamically build a struct that json.Marshal handles natively,
|
||||
// avoiding manual JSON string concatenation.
|
||||
func (r row) MarshalJSON() ([]byte, error) {
|
||||
ev := reflect.ValueOf(r.entry)
|
||||
et := ev.Type()
|
||||
|
||||
fields := make([]reflect.StructField, 0, et.NumField()+2)
|
||||
fields = append(fields,
|
||||
reflect.StructField{Name: "Browser", Type: reflect.TypeOf(""), Tag: `json:"browser"`},
|
||||
reflect.StructField{Name: "Profile", Type: reflect.TypeOf(""), Tag: `json:"profile"`},
|
||||
)
|
||||
for i := 0; i < et.NumField(); i++ {
|
||||
fields = append(fields, et.Field(i))
|
||||
}
|
||||
|
||||
flat := reflect.New(reflect.StructOf(fields)).Elem()
|
||||
flat.Field(0).SetString(r.Browser)
|
||||
flat.Field(1).SetString(r.Profile)
|
||||
for i := 0; i < et.NumField(); i++ {
|
||||
flat.Field(i + 2).Set(ev.Field(i))
|
||||
}
|
||||
|
||||
return json.Marshal(flat.Interface())
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
# RFC-004: CLI (Cobra) and Output Design
|
||||
|
||||
**Author**: moonD4rk
|
||||
**Status**: Proposed
|
||||
**Created**: 2026-04-03
|
||||
**Updated**: 2026-04-03
|
||||
|
||||
## Context
|
||||
|
||||
v2 architecture delivers `Extract() → *types.BrowserData`. The remaining
|
||||
pieces are: CLI for user interaction and output for writing results to files.
|
||||
Current CLI uses `urfave/cli` with flat flags; migrating to `cobra` with
|
||||
subcommands for better extensibility.
|
||||
|
||||
## 1. CLI Design
|
||||
|
||||
### Subcommands
|
||||
|
||||
```
|
||||
hack-browser-data
|
||||
├── dump # extract browser data (default when no subcommand)
|
||||
│ ├── -b, --browser all|chrome|firefox|... (default: all)
|
||||
│ ├── -c, --category all|password,cookie,... (default: all)
|
||||
│ ├── -f, --format csv|json|cookie-editor (default: csv)
|
||||
│ ├── -d, --dir output directory (default: results)
|
||||
│ ├── -p, --profile-path custom profile path
|
||||
│ ├── --keychain-pw macOS keychain password
|
||||
│ └── --zip compress output
|
||||
│
|
||||
├── list # show detected browsers and profile paths
|
||||
│ └── --detail show per-category entry counts (no decryption)
|
||||
│
|
||||
└── global flags
|
||||
├── -v, --verbose
|
||||
└── --version
|
||||
```
|
||||
|
||||
Running `hack-browser-data` with no subcommand defaults to `dump`.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
hack-browser-data # dump all
|
||||
hack-browser-data dump -b chrome -c password,cookie # specific
|
||||
hack-browser-data dump -b chrome -f json # JSON output
|
||||
hack-browser-data dump -f cookie-editor # CookieEditor format
|
||||
hack-browser-data list # show browsers
|
||||
hack-browser-data list --detail # show counts
|
||||
```
|
||||
|
||||
### Removed/changed flags vs current CLI
|
||||
|
||||
| Current flag | Action | Reason |
|
||||
|-------------|--------|--------|
|
||||
| `--full-export` | Removed | Replaced by `--category all` (default) |
|
||||
| `--results-dir` | Renamed `--dir` | Shorter |
|
||||
| — | New `--category` | Fine-grained control |
|
||||
| — | New `--keychain-pw` | macOS keychain password |
|
||||
| — | New `--format cookie-editor` | CookieEditor compatibility |
|
||||
|
||||
### Code structure
|
||||
|
||||
```
|
||||
cmd/hack-browser-data/
|
||||
├── main.go # cobra root command setup
|
||||
├── dump.go # dump subcommand
|
||||
└── list.go # list subcommand
|
||||
```
|
||||
|
||||
## 2. Output Design
|
||||
|
||||
### File organization
|
||||
|
||||
One file per category. Browser and profile are columns, not filenames:
|
||||
|
||||
```
|
||||
results/
|
||||
├── password.csv
|
||||
├── cookie.csv
|
||||
├── history.csv
|
||||
├── bookmark.csv
|
||||
├── download.csv
|
||||
├── extension.csv
|
||||
├── creditcard.csv
|
||||
├── localstorage.csv
|
||||
└── sessionstorage.csv
|
||||
```
|
||||
|
||||
At most 9 files, regardless of how many browsers/profiles.
|
||||
|
||||
Example `password.csv`:
|
||||
```
|
||||
browser,profile,url,username,password,created_at
|
||||
Chrome,Default,https://example.com,alice,xxx,2026-01-01
|
||||
Chrome,Profile 1,https://github.com,bob,yyy,2026-02-01
|
||||
Firefox,abc123.default,https://reddit.com,charlie,zzz,2026-03-01
|
||||
```
|
||||
|
||||
Example `password.json`:
|
||||
```json
|
||||
[
|
||||
{"browser":"Chrome","profile":"Default","url":"https://example.com","username":"alice","password":"xxx","created_at":"2026-01-01T00:00:00Z"},
|
||||
{"browser":"Firefox","profile":"abc123.default","url":"https://reddit.com","username":"charlie","password":"zzz","created_at":"2026-03-01T00:00:00Z"}
|
||||
]
|
||||
```
|
||||
|
||||
### Architecture: encapsulated Writer struct
|
||||
|
||||
The `Writer` struct is the only exported type. All internals (formatter,
|
||||
row types, file management) are unexported. Caller sees 3 methods only.
|
||||
|
||||
```go
|
||||
// output/output.go — the only exported type
|
||||
|
||||
type Writer struct {
|
||||
dir string
|
||||
formatter formatter // unexported
|
||||
results []result // unexported
|
||||
}
|
||||
|
||||
func NewWriter(dir, format string) (*Writer, error) {
|
||||
f, err := newFormatter(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Writer{dir: dir, formatter: f}, nil
|
||||
}
|
||||
|
||||
func (w *Writer) Add(browser, profile string, data *types.BrowserData) {
|
||||
w.results = append(w.results, result{browser, profile, data})
|
||||
}
|
||||
|
||||
func (w *Writer) Write() error {
|
||||
// 1. aggregate all results by category into row slices
|
||||
// 2. for each non-empty category, format to buffer, write file
|
||||
}
|
||||
```
|
||||
|
||||
Caller code (3 lines):
|
||||
|
||||
```go
|
||||
w, _ := output.NewWriter(dir, "csv")
|
||||
for _, b := range browsers {
|
||||
data, _ := b.Extract(categories)
|
||||
w.Add(b.BrowserName(), b.ProfileName(), data)
|
||||
}
|
||||
w.Write()
|
||||
```
|
||||
|
||||
### Data layer stays pure
|
||||
|
||||
Entry structs do NOT contain browser/profile. Each field carries both
|
||||
`json` and `csv` struct tags — JSON output reads `json` tags, CSV output
|
||||
reads `csv` tags via reflection. No methods on entry types.
|
||||
|
||||
```go
|
||||
// types/models.go — pure data, no methods
|
||||
type LoginEntry struct {
|
||||
URL string `json:"url" csv:"url"`
|
||||
Username string `json:"username" csv:"username"`
|
||||
Password string `json:"password" csv:"password"`
|
||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### Internal row type (unexported)
|
||||
|
||||
A single `row` type wraps any entry with browser/profile context:
|
||||
|
||||
```go
|
||||
// output/row.go — unexported
|
||||
|
||||
type row struct {
|
||||
Browser string
|
||||
Profile string
|
||||
entry any
|
||||
}
|
||||
```
|
||||
|
||||
- **CSV**: `row.csvHeader()` / `row.csvRow()` use reflection to read `csv`
|
||||
struct tags and convert field values to strings (handles string, bool,
|
||||
int, int64, time.Time).
|
||||
- **JSON**: `row.MarshalJSON()` uses `reflect.StructOf` to dynamically
|
||||
build a flat struct with browser/profile fields followed by entry fields,
|
||||
then delegates to `json.Marshal`. No manual string concatenation.
|
||||
|
||||
### Internal formatter interface (unexported)
|
||||
|
||||
```go
|
||||
// output/formatter.go — unexported
|
||||
|
||||
type formatter interface {
|
||||
format(w io.Writer, rows []row) error
|
||||
ext() string
|
||||
}
|
||||
|
||||
func newFormatter(name string) (formatter, error) {
|
||||
switch name {
|
||||
case "csv": return &csvFormatter{}, nil
|
||||
case "json": return &jsonFormatter{}, nil
|
||||
case "cookie-editor": return &cookieEditorFormatter{}, nil
|
||||
default: return nil, fmt.Errorf("unsupported format: %s", name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Format support
|
||||
|
||||
**CSV** (default):
|
||||
- Standard `encoding/csv` — **no gocsv dependency**
|
||||
- UTF-8 BOM for Excel compatibility
|
||||
- Headers and values derived from `csv` struct tags via reflection
|
||||
|
||||
**JSON**:
|
||||
- Valid JSON Array per file (not JSON Lines)
|
||||
- Pretty-printed with `json.Encoder`, no HTML escape
|
||||
- `reflect.StructOf` dynamically flattens browser/profile + entry fields
|
||||
|
||||
**CookieEditor** (`--format cookie-editor`):
|
||||
- Only exports cookies, other categories skipped
|
||||
- Field mapping: host→domain, IsSecure→secure, ExpireAt→expirationDate (unix)
|
||||
|
||||
### Dependency changes
|
||||
|
||||
- **Remove**: `github.com/gocarina/gocsv`
|
||||
- **Remove**: `golang.org/x/text` (UTF-8 BOM = 3 bytes directly)
|
||||
- **Add**: `github.com/spf13/cobra`
|
||||
|
||||
### Output package structure
|
||||
|
||||
```
|
||||
output/
|
||||
├── output.go # Writer struct (exported): NewWriter(), Add(), Write()
|
||||
├── row.go # Unified row type (unexported) + MarshalJSON
|
||||
├── reflect.go # Reflection helpers: csv tag parsing, field formatting
|
||||
├── formatter.go # formatter interface (unexported) + newFormatter()
|
||||
├── csv.go # csvFormatter (unexported)
|
||||
├── json.go # jsonFormatter (unexported)
|
||||
└── cookie_editor.go # cookieEditorFormatter (unexported)
|
||||
```
|
||||
|
||||
## 3. `list` Command
|
||||
|
||||
### Basic mode
|
||||
|
||||
Shows real filesystem paths detected by `NewBrowsers`. No database access.
|
||||
|
||||
```
|
||||
$ hack-browser-data list
|
||||
|
||||
Browser Profile Path
|
||||
Chrome Default /Users/x/Library/.../Google/Chrome/Default
|
||||
Chrome Profile 1 /Users/x/Library/.../Google/Chrome/Profile 1
|
||||
Firefox abc123.default-release /Users/x/Library/.../Firefox/Profiles/abc123...
|
||||
```
|
||||
|
||||
### Detail mode (`--detail`)
|
||||
|
||||
Counts entries per category without decryption:
|
||||
|
||||
```
|
||||
$ hack-browser-data list --detail
|
||||
|
||||
Browser Profile Password Cookie History Bookmark Extension
|
||||
Chrome Default 1 3544 66 852 39
|
||||
Chrome Profile 1 2 802 32 0 3
|
||||
Firefox abc123.default-release 3 48 53 7 0
|
||||
```
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
CLI (cobra dump)
|
||||
→ Parse flags: browser, category, format, dir, keychain-pw
|
||||
→ browser.Pick(browserName, keychainPwd) → []Browser
|
||||
→ w, _ := output.NewWriter(dir, format)
|
||||
→ For each browser:
|
||||
→ data, _ := b.Extract(categories)
|
||||
→ w.Add(b.BrowserName(), b.ProfileName(), data)
|
||||
→ w.Write()
|
||||
→ Optional: compress dir to zip
|
||||
|
||||
CLI (cobra list)
|
||||
→ browser.Pick("all", "") → []Browser
|
||||
→ For each browser:
|
||||
→ Print BrowserName() + ProfileName() + profileDir
|
||||
→ If --detail: Extract + count entries
|
||||
```
|
||||
|
||||
## 5. Implementation status
|
||||
|
||||
- [x] `output/` package: Writer struct + unified row type + reflection-based CSV/JSON + formatters
|
||||
- [x] `types/category.go`: removed Each() and CategoryData
|
||||
- [x] `types/models.go`: pure data structs with `json` + `csv` tags, no methods
|
||||
- [x] Tests: 27 tests covering CSV/JSON/CookieEditor output, reflection helpers, MarshalJSON, csv tag coverage
|
||||
- [ ] (PR 2) Rewrite browser dispatch + cobra CLI
|
||||
- [ ] (PR 3) Delete old code + rename files
|
||||
|
||||
## 6. Future extensions
|
||||
|
||||
- `--group-by browser` — one file per browser+category (group by browser)
|
||||
- `--group-by profile` — one file per browser+profile+category (group by profile)
|
||||
- `--format netscape` — Netscape cookie.txt format (curl/wget compatible)
|
||||
- `--format har` — HAR (HTTP Archive) format
|
||||
+33
-33
@@ -4,75 +4,75 @@ import "time"
|
||||
|
||||
// LoginEntry represents a single saved login credential.
|
||||
type LoginEntry struct {
|
||||
URL string `json:"url" csv:"url"`
|
||||
Username string `json:"username" csv:"username"`
|
||||
Password string `json:"password" csv:"password"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
Username string `json:"username" csv:"username"`
|
||||
Password string `json:"password" csv:"password"`
|
||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||
}
|
||||
|
||||
// CookieEntry represents a single browser cookie.
|
||||
type CookieEntry struct {
|
||||
Host string `json:"host" csv:"host"`
|
||||
Path string `json:"path" csv:"path"`
|
||||
Name string `json:"name" csv:"name"`
|
||||
Value string `json:"value" csv:"value"`
|
||||
IsSecure bool `json:"is_secure" csv:"is_secure"`
|
||||
IsHTTPOnly bool `json:"is_http_only" csv:"is_http_only"`
|
||||
HasExpire bool `json:"has_expire" csv:"has_expire"`
|
||||
Host string `json:"host" csv:"host"`
|
||||
Path string `json:"path" csv:"path"`
|
||||
Name string `json:"name" csv:"name"`
|
||||
Value string `json:"value" csv:"value"`
|
||||
IsSecure bool `json:"is_secure" csv:"is_secure"`
|
||||
IsHTTPOnly bool `json:"is_http_only" csv:"is_http_only"`
|
||||
HasExpire bool `json:"has_expire" csv:"has_expire"`
|
||||
IsPersistent bool `json:"is_persistent" csv:"is_persistent"`
|
||||
ExpireAt time.Time `json:"expire_at" csv:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||
ExpireAt time.Time `json:"expire_at" csv:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||
}
|
||||
|
||||
// BookmarkEntry represents a single browser bookmark.
|
||||
type BookmarkEntry struct {
|
||||
Name string `json:"name" csv:"name"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
Folder string `json:"folder" csv:"folder"`
|
||||
Name string `json:"name" csv:"name"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
Folder string `json:"folder" csv:"folder"`
|
||||
CreatedAt time.Time `json:"created_at" csv:"created_at"`
|
||||
}
|
||||
|
||||
// HistoryEntry represents a single browser history record.
|
||||
type HistoryEntry struct {
|
||||
URL string `json:"url" csv:"url"`
|
||||
Title string `json:"title" csv:"title"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
Title string `json:"title" csv:"title"`
|
||||
VisitCount int `json:"visit_count" csv:"visit_count"`
|
||||
LastVisit time.Time `json:"last_visit" csv:"last_visit"`
|
||||
LastVisit time.Time `json:"last_visit" csv:"last_visit"`
|
||||
}
|
||||
|
||||
// DownloadEntry represents a single browser download record.
|
||||
type DownloadEntry struct {
|
||||
URL string `json:"url" csv:"url"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
TargetPath string `json:"target_path" csv:"target_path"`
|
||||
MimeType string `json:"mime_type" csv:"mime_type"`
|
||||
MimeType string `json:"mime_type" csv:"mime_type"`
|
||||
TotalBytes int64 `json:"total_bytes" csv:"total_bytes"`
|
||||
StartTime time.Time `json:"start_time" csv:"start_time"`
|
||||
EndTime time.Time `json:"end_time" csv:"end_time"`
|
||||
StartTime time.Time `json:"start_time" csv:"start_time"`
|
||||
EndTime time.Time `json:"end_time" csv:"end_time"`
|
||||
}
|
||||
|
||||
// CreditCardEntry represents a single saved credit card.
|
||||
type CreditCardEntry struct {
|
||||
Name string `json:"name" csv:"name"`
|
||||
Number string `json:"number" csv:"number"`
|
||||
Name string `json:"name" csv:"name"`
|
||||
Number string `json:"number" csv:"number"`
|
||||
ExpMonth string `json:"exp_month" csv:"exp_month"`
|
||||
ExpYear string `json:"exp_year" csv:"exp_year"`
|
||||
ExpYear string `json:"exp_year" csv:"exp_year"`
|
||||
NickName string `json:"nick_name" csv:"nick_name"`
|
||||
Address string `json:"address" csv:"address"`
|
||||
Address string `json:"address" csv:"address"`
|
||||
}
|
||||
|
||||
// StorageEntry represents a single key-value pair from local or session storage.
|
||||
type StorageEntry struct {
|
||||
URL string `json:"url" csv:"url"`
|
||||
Key string `json:"key" csv:"key"`
|
||||
URL string `json:"url" csv:"url"`
|
||||
Key string `json:"key" csv:"key"`
|
||||
Value string `json:"value" csv:"value"`
|
||||
}
|
||||
|
||||
// ExtensionEntry represents a single browser extension.
|
||||
type ExtensionEntry struct {
|
||||
Name string `json:"name" csv:"name"`
|
||||
ID string `json:"id" csv:"id"`
|
||||
Description string `json:"description" csv:"description"`
|
||||
Version string `json:"version" csv:"version"`
|
||||
Name string `json:"name" csv:"name"`
|
||||
ID string `json:"id" csv:"id"`
|
||||
Description string `json:"description" csv:"description"`
|
||||
Version string `json:"version" csv:"version"`
|
||||
HomepageURL string `json:"homepage_url" csv:"homepage_url"`
|
||||
Enabled bool `json:"enabled" csv:"enabled"`
|
||||
Enabled bool `json:"enabled" csv:"enabled"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user