From 00ad0e0bd4de21570a5e4ae48bfc0552a30e33f4 Mon Sep 17 00:00:00 2001 From: Roger Date: Sat, 4 Apr 2026 01:17:55 +0800 Subject: [PATCH] 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 --- .golangci.yml | 3 + browser/chromium/chromium_new.go | 16 +- browser/chromium/chromium_new_test.go | 1 - browser/firefox/firefox_new.go | 14 +- browser/firefox/firefox_new_test.go | 10 +- output/cookie_editor.go | 51 +++++ output/csv.go | 28 +++ output/formatter.go | 25 ++ output/json.go | 17 ++ output/output.go | 160 +++++++++++++ output/output_test.go | 316 ++++++++++++++++++++++++++ output/reflect.go | 89 ++++++++ output/reflect_test.go | 195 ++++++++++++++++ output/row.go | 47 ++++ rfcs/004-cli-and-output.md | 304 +++++++++++++++++++++++++ types/models.go | 66 +++--- 16 files changed, 1290 insertions(+), 52 deletions(-) create mode 100644 output/cookie_editor.go create mode 100644 output/csv.go create mode 100644 output/formatter.go create mode 100644 output/json.go create mode 100644 output/output.go create mode 100644 output/output_test.go create mode 100644 output/reflect.go create mode 100644 output/reflect_test.go create mode 100644 output/row.go create mode 100644 rfcs/004-cli-and-output.md diff --git a/.golangci.yml b/.golangci.yml index 6472351..98267a7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -69,6 +69,9 @@ linters: goconst: min-len: 2 min-occurrences: 3 + ignore-string-values: + - "csv" + - "json" gocritic: enabled-tags: - diagnostic diff --git a/browser/chromium/chromium_new.go b/browser/chromium/chromium_new.go index 089d696..904c3bc 100644 --- a/browser/chromium/chromium_new.go +++ b/browser/chromium/chromium_new.go @@ -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) } } diff --git a/browser/chromium/chromium_new_test.go b/browser/chromium/chromium_new_test.go index a776641..e4e1e82 100644 --- a/browser/chromium/chromium_new_test.go +++ b/browser/chromium/chromium_new_test.go @@ -369,7 +369,6 @@ func TestExtractCategory_DefaultFallback(t *testing.T) { ) b := &Browser{ - name: "Test", extractors: nil, // no custom extractors } diff --git a/browser/firefox/firefox_new.go b/browser/firefox/firefox_new.go index ef72bd3..1a170fd 100644 --- a/browser/firefox/firefox_new.go +++ b/browser/firefox/firefox_new.go @@ -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) } } diff --git a/browser/firefox/firefox_new_test.go b/browser/firefox/firefox_new_test.go index 29f56a9..50f8c04 100644 --- a/browser/firefox/firefox_new_test.go +++ b/browser/firefox/firefox_new_test.go @@ -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") diff --git a/output/cookie_editor.go b/output/cookie_editor.go new file mode 100644 index 0000000..07aac32 --- /dev/null +++ b/output/cookie_editor.go @@ -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"` +} diff --git a/output/csv.go b/output/csv.go new file mode 100644 index 0000000..bf45e0d --- /dev/null +++ b/output/csv.go @@ -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() +} diff --git a/output/formatter.go b/output/formatter.go new file mode 100644 index 0000000..f178bb5 --- /dev/null +++ b/output/formatter.go @@ -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) + } +} diff --git a/output/json.go b/output/json.go new file mode 100644 index 0000000..ccc9c06 --- /dev/null +++ b/output/json.go @@ -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) +} diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..c272479 --- /dev/null +++ b/output/output.go @@ -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 +} diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..c1f15b4 --- /dev/null +++ b/output/output_test.go @@ -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)) +} diff --git a/output/reflect.go b/output/reflect.go new file mode 100644 index 0000000..6278cb5 --- /dev/null +++ b/output/reflect.go @@ -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" +} diff --git a/output/reflect_test.go b/output/reflect_test.go new file mode 100644 index 0000000..b338392 --- /dev/null +++ b/output/reflect_test.go @@ -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`, + }, + } + + 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`, m["password"]) + }) +} diff --git a/output/row.go b/output/row.go new file mode 100644 index 0000000..513f9cf --- /dev/null +++ b/output/row.go @@ -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()) +} diff --git a/rfcs/004-cli-and-output.md b/rfcs/004-cli-and-output.md new file mode 100644 index 0000000..797c7e4 --- /dev/null +++ b/rfcs/004-cli-and-output.md @@ -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 diff --git a/types/models.go b/types/models.go index ded245d..a090579 100644 --- a/types/models.go +++ b/types/models.go @@ -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"` }