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
+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())
}