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:
Roger
2026-04-04 01:17:55 +08:00
committed by moonD4rk
parent 1a3aea553e
commit 00ad0e0bd4
16 changed files with 1290 additions and 52 deletions
+3
View File
@@ -69,6 +69,9 @@ linters:
goconst:
min-len: 2
min-occurrences: 3
ignore-string-values:
- "csv"
- "json"
gocritic:
enabled-tags:
- diagnostic
+9 -7
View File
@@ -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)
}
}
-1
View File
@@ -369,7 +369,6 @@ func TestExtractCategory_DefaultFallback(t *testing.T) {
)
b := &Browser{
name: "Test",
extractors: nil, // no custom extractors
}
+8 -6
View File
@@ -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)
}
}
+5 -5
View File
@@ -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")
+51
View File
@@ -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"`
}
+28
View File
@@ -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()
}
+25
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
+160
View File
@@ -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
}
+316
View File
@@ -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))
}
+89
View File
@@ -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"
}
+195
View File
@@ -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"])
})
}
+47
View File
@@ -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())
}
+304
View File
@@ -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
View File
@@ -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"`
}