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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user