pwstore: generic csvManager for 8 password-manager CSV adapters
One data-driven csvManager (layout = header + record func) covers edge/brave (chrome layout), safari, lastpass, dashlane, protonpass, nordpass, roboform, and read/guide-only enpass — replacing 8 near-identical files. Export is header-name driven (parseLoginCSV + new aliases); import stages a per-manager-layout CSV on tmpfs and shreds it, gated by --allow-csv. Notes now round-trip through Meta. Reimporter exposes per-manager re-import hints. Column layouts are MOCK-ONLY (unit-validated) on the same LIVE-VM staging machinery; recorded as DATA in BROWSER-ROTATION.md §8. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+13
-6
@@ -35,11 +35,13 @@ func colsFromHeader(header []string) csvCols {
|
||||
}
|
||||
return csvCols{
|
||||
name: idx("name", "title"),
|
||||
url: idx("url", "login_uri", "web site", "website"),
|
||||
username: idx("username", "login_username", "user"),
|
||||
password: idx("password", "login_password", "pass"),
|
||||
note: idx("note", "notes"),
|
||||
group: idx("group"), // KeePassXC export column; absent elsewhere (-1)
|
||||
url: idx("url", "login_uri", "web site", "website", "matchurl"),
|
||||
username: idx("username", "login_username", "user", "login"),
|
||||
password: idx("password", "login_password", "pass", "pwd"),
|
||||
note: idx("note", "notes", "extra"),
|
||||
// group: KeePassXC "group"; LastPass "grouping"; Dashlane "category";
|
||||
// NordPass/RoboForm "folder"; Proton "vault". Absent elsewhere (-1).
|
||||
group: idx("group", "grouping", "category", "folder", "vault"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +87,11 @@ func parseLoginCSV(v *vault.Vault, data []byte) ([]Account, error) {
|
||||
if g := get(rec, cols.group); g != "" {
|
||||
a.Meta["group"] = g
|
||||
}
|
||||
// Preserve notes so a manager round-trip (export -> import CSV) does not wipe
|
||||
// the user's existing note on the entry. Notes are non-secret metadata.
|
||||
if nt := get(rec, cols.note); nt != "" {
|
||||
a.Meta["note"] = nt
|
||||
}
|
||||
pw := get(rec, cols.password)
|
||||
a.Secret = v.Store([]byte(pw))
|
||||
out = append(out, a)
|
||||
@@ -110,7 +117,7 @@ type csvLayout struct {
|
||||
var chromeLayout = csvLayout{
|
||||
header: chromeCSVHeader,
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Meta["name"], a.URL, a.Username, pw, ""}
|
||||
return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"]}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package pwstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// csvManager is the shared adapter for every password manager / browser whose ONLY
|
||||
// supported propagation path is a plaintext CSV the user re-imports by hand. It is the
|
||||
// generalization of the Chrome/Firefox staging path (docs/BROWSER-ROTATION.md §3) to the
|
||||
// rest of the CSV-import field (LastPass, Dashlane, Proton Pass, NordPass, RoboForm,
|
||||
// Safari, Edge, Brave, …): read = the CSV the user exported; write = a tmpfs CSV in that
|
||||
// manager's own import column layout, securely shredded after re-import, gated behind the
|
||||
// caller's --allow-csv opt-in. No plaintext secret ever rests on stable storage (hard
|
||||
// rule 3) — identical guarantees to ChromeCSV/FirefoxCSV, just data-driven by layout.
|
||||
//
|
||||
// Each backend differs ONLY in (1) its name, (2) the column layout its import expects, and
|
||||
// (3) the reimport hint (where the human pastes the file). Export uses the shared,
|
||||
// header-name-driven parseLoginCSV, so one type serves them all. A manager with no clean
|
||||
// CSV import (e.g. Enpass, whose import is JSON) registers with a nil layout: it is
|
||||
// read/guide-only and ImportStaged refuses with a pointer to the guided worklist.
|
||||
type csvManager struct {
|
||||
name string
|
||||
layout csvLayout // import layout; record==nil => CSV import unsupported (read-only)
|
||||
reimport string // human instruction: where to re-import the staged CSV
|
||||
|
||||
// ExportPath is the CSV the user exported from this manager (read side). Optional;
|
||||
// if empty, Export returns an error and Available() is false.
|
||||
ExportPath string
|
||||
importPath string // last staged path written by ImportStaged
|
||||
}
|
||||
|
||||
// csvManagerSpec is one backend's import layout + reimport hint, keyed by name.
|
||||
type csvManagerSpec struct {
|
||||
layout csvLayout
|
||||
reimport string
|
||||
}
|
||||
|
||||
// csvManagerSpecs is the table of CSV-based backends. Adding a manager is one entry here
|
||||
// (and, if its export header uses new column names, an alias in colsFromHeader).
|
||||
var csvManagerSpecs = map[string]csvManagerSpec{
|
||||
// Chromium browsers re-import Chrome's exact CSV shape.
|
||||
"edge": {chromeLayout, "re-import at edge://wallet/passwords/settings ▸ Import"},
|
||||
"brave": {chromeLayout, "re-import at brave://password-manager/passwords ▸ Import"},
|
||||
|
||||
"lastpass": {lastpassLayout, "import at LastPass ▸ Advanced Options ▸ Import ▸ Generic CSV"},
|
||||
"dashlane": {dashlaneLayout, "import at Dashlane ▸ My Account ▸ Import data ▸ CSV"},
|
||||
"protonpass": {protonLayout, "import at Proton Pass ▸ Settings ▸ Import ▸ CSV"},
|
||||
"nordpass": {nordpassLayout, "import at NordPass ▸ Settings ▸ Import/Export ▸ CSV"},
|
||||
"roboform": {roboformLayout, "import at RoboForm ▸ Options ▸ Account & Data ▸ Import"},
|
||||
"safari": {safariLayout, "re-import in the macOS Passwords app ▸ File ▸ Import Passwords"},
|
||||
|
||||
// Enpass imports via JSON, not CSV — read/guide-only here (nil layout).
|
||||
"enpass": {csvLayout{}, ""},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for name, spec := range csvManagerSpecs {
|
||||
Register(&csvManager{name: name, layout: spec.layout, reimport: spec.reimport})
|
||||
}
|
||||
}
|
||||
|
||||
// NewCSV builds a configured CSV-based adapter for the named manager, reading from
|
||||
// exportPath. It is the constructor the CLI uses (the registry singletons are zero-valued
|
||||
// for listing only). Unknown names return an error so the caller can fall through.
|
||||
func NewCSV(name, exportPath string) (Manager, error) {
|
||||
spec, ok := csvManagerSpecs[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("pwstore: %q is not a CSV-based manager", name)
|
||||
}
|
||||
return &csvManager{name: name, layout: spec.layout, reimport: spec.reimport, ExportPath: exportPath}, nil
|
||||
}
|
||||
|
||||
// IsCSV reports whether name is a registered CSV-based manager.
|
||||
func IsCSV(name string) bool {
|
||||
_, ok := csvManagerSpecs[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c *csvManager) Name() string { return c.name }
|
||||
|
||||
// Available reports whether an export CSV was supplied and exists.
|
||||
func (c *csvManager) Available() bool {
|
||||
if c.ExportPath == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(c.ExportPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Export reads the user-provided export CSV into Accounts (passwords land in the vault as
|
||||
// handles). It never shreds the user's own export — that file is the user's to manage; we
|
||||
// only own the file we ourselves write in ImportStaged.
|
||||
func (c *csvManager) Export(ctx context.Context, v *vault.Vault) ([]Account, error) {
|
||||
if c.ExportPath == "" {
|
||||
return nil, fmt.Errorf("%s: no export CSV path set", c.name)
|
||||
}
|
||||
data, err := os.ReadFile(c.ExportPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: read export: %w", c.name, err)
|
||||
}
|
||||
return parseLoginCSV(v, data)
|
||||
}
|
||||
|
||||
// ImportStaged materializes accts (carrying NEW passwords already swapped into Secret, or
|
||||
// via newPw by index) as a CSV in this manager's import layout on tmpfs, returning its path
|
||||
// plus a cleanup func the caller MUST defer after the human confirms re-import. A manager
|
||||
// without a CSV import format refuses here and directs the user to the guided worklist.
|
||||
func (c *csvManager) ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (string, func(), error) {
|
||||
if c.layout.record == nil {
|
||||
return "", nil, fmt.Errorf("%s: no CSV import format — rotate via `incredigo passwords guide` (worklist + change-URLs) instead", c.name)
|
||||
}
|
||||
path, cleanup, err := writeShreddableCSVLayout(v, accts, newPw, c.layout)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
c.importPath = path
|
||||
return path, cleanup, nil
|
||||
}
|
||||
|
||||
// ReimportHint returns the manager-specific instruction for where to re-import the staged
|
||||
// CSV, so the commit path can tell the user exactly where to go.
|
||||
func (c *csvManager) ReimportHint() string { return c.reimport }
|
||||
|
||||
// ---- per-manager import layouts (the read side is header-driven via colsFromHeader) ----
|
||||
|
||||
// LastPass "Generic CSV" import: url,username,password,totp,extra,name,grouping,fav.
|
||||
var lastpassLayout = csvLayout{
|
||||
header: []string{"url", "username", "password", "totp", "extra", "name", "grouping", "fav"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.URL, a.Username, pw, "", a.Meta["note"], a.Meta["name"], a.Meta["group"], ""}
|
||||
},
|
||||
}
|
||||
|
||||
// Dashlane CSV import: username,username2,username3,title,password,note,url,category,otpSecret.
|
||||
var dashlaneLayout = csvLayout{
|
||||
header: []string{"username", "username2", "username3", "title", "password", "note", "url", "category", "otpSecret"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Username, "", "", a.Meta["name"], pw, a.Meta["note"], a.URL, a.Meta["group"], ""}
|
||||
},
|
||||
}
|
||||
|
||||
// Proton Pass CSV import: name,url,email,username,password,note,totp,vault. Proton keeps
|
||||
// email and username separate; we map the resolved login into username and leave email blank.
|
||||
var protonLayout = csvLayout{
|
||||
header: []string{"name", "url", "email", "username", "password", "note", "totp", "vault"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Meta["name"], a.URL, "", a.Username, pw, a.Meta["note"], "", a.Meta["group"]}
|
||||
},
|
||||
}
|
||||
|
||||
// NordPass CSV import: name,url,username,password,note,folder.
|
||||
var nordpassLayout = csvLayout{
|
||||
header: []string{"name", "url", "username", "password", "note", "folder"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"], a.Meta["group"]}
|
||||
},
|
||||
}
|
||||
|
||||
// RoboForm CSV import: Name,Url,MatchUrl,Login,Pwd,Note,Folder.
|
||||
var roboformLayout = csvLayout{
|
||||
header: []string{"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Meta["name"], a.URL, a.URL, a.Username, pw, a.Meta["note"], a.Meta["group"]}
|
||||
},
|
||||
}
|
||||
|
||||
// Safari / macOS Passwords app CSV import: Title,URL,Username,Password,Notes,OTPAuth.
|
||||
var safariLayout = csvLayout{
|
||||
header: []string{"Title", "URL", "Username", "Password", "Notes", "OTPAuth"},
|
||||
record: func(a Account, pw string) []string {
|
||||
return []string{a.Meta["name"], a.URL, a.Username, pw, a.Meta["note"], ""}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package pwstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// writeCSV writes body to a temp file and returns its path.
|
||||
func writeCSV(t *testing.T, name, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestColsFromHeaderAliases proves the new header aliases resolve the right columns so
|
||||
// each manager's export parses without a bespoke parser.
|
||||
func TestColsFromHeaderAliases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header []string
|
||||
// expected resolved indices (-1 if absent)
|
||||
user, pass, note, group int
|
||||
}{
|
||||
{"roboform", []string{"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"}, 3, 4, 5, 6},
|
||||
{"lastpass", []string{"url", "username", "password", "totp", "extra", "name", "grouping", "fav"}, 1, 2, 4, 6},
|
||||
{"dashlane", []string{"username", "title", "password", "note", "url", "category"}, 0, 2, 3, 5},
|
||||
{"nordpass", []string{"name", "url", "username", "password", "note", "folder"}, 2, 3, 4, 5},
|
||||
{"proton", []string{"name", "url", "email", "username", "password", "note", "vault"}, 3, 4, 5, 6},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := colsFromHeader(c.header)
|
||||
if got.username != c.user || got.password != c.pass || got.note != c.note || got.group != c.group {
|
||||
t.Errorf("%s: cols=%+v want user=%d pass=%d note=%d group=%d",
|
||||
c.name, got, c.user, c.pass, c.note, c.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVManagerExportParsesAliases reads a representative export for each manager and
|
||||
// asserts the login fields (incl. note via Meta) come through, with the password as a
|
||||
// vault handle.
|
||||
func TestCSVManagerExportParsesAliases(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
cases := []struct {
|
||||
mgr, file, body string
|
||||
}{
|
||||
{"roboform", "rf.csv",
|
||||
"Name,Url,MatchUrl,Login,Pwd,Note,Folder\n" +
|
||||
"GitHub,https://github.com/,https://github.com/,alice@example.com,old1,my note,Dev\n"},
|
||||
{"lastpass", "lp.csv",
|
||||
"url,username,password,totp,extra,name,grouping,fav\n" +
|
||||
"https://github.com/,alice@example.com,old1,,my note,GitHub,Dev,0\n"},
|
||||
{"protonpass", "pp.csv",
|
||||
"name,url,email,username,password,note,totp,vault\n" +
|
||||
"GitHub,https://github.com/,alice@personal.com,alice@example.com,old1,my note,,Personal\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
path := writeCSV(t, c.file, c.body)
|
||||
m, err := NewCSV(c.mgr, path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: NewCSV: %v", c.mgr, err)
|
||||
}
|
||||
if !m.Available() {
|
||||
t.Errorf("%s: Available should be true with an existing export", c.mgr)
|
||||
}
|
||||
accts, err := m.Export(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Export: %v", c.mgr, err)
|
||||
}
|
||||
if len(accts) != 1 {
|
||||
t.Fatalf("%s: got %d accounts, want 1", c.mgr, len(accts))
|
||||
}
|
||||
a := accts[0]
|
||||
if a.Site != "github.com" {
|
||||
t.Errorf("%s: site = %q, want github.com", c.mgr, a.Site)
|
||||
}
|
||||
if a.Username != "alice@example.com" {
|
||||
t.Errorf("%s: username = %q (alias must resolve the login column, not email)", c.mgr, a.Username)
|
||||
}
|
||||
if a.Meta["note"] != "my note" {
|
||||
t.Errorf("%s: note = %q, want preserved 'my note'", c.mgr, a.Meta["note"])
|
||||
}
|
||||
if pw := handleStr(t, v, a.Secret); pw != "old1" {
|
||||
t.Errorf("%s: password = %q, want old1", c.mgr, pw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVManagerImportStagedLayout proves each manager's staged CSV uses ITS import header,
|
||||
// carries the substituted NEW password, preserves name/group/note, lands on tmpfs, and is
|
||||
// shredded on cleanup.
|
||||
func TestCSVManagerImportStagedLayout(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
accts := []Account{{
|
||||
URL: "https://github.com/",
|
||||
Username: "alice@example.com",
|
||||
Secret: v.Store([]byte("OLD")),
|
||||
Meta: map[string]string{"name": "GitHub", "group": "Dev", "note": "my note"},
|
||||
}}
|
||||
newPw := map[int]*vault.Handle{0: v.Store([]byte("NEW-PASS-1"))}
|
||||
|
||||
wantHeader := map[string][]string{
|
||||
"lastpass": {"url", "username", "password", "totp", "extra", "name", "grouping", "fav"},
|
||||
"dashlane": {"username", "username2", "username3", "title", "password", "note", "url", "category", "otpSecret"},
|
||||
"protonpass": {"name", "url", "email", "username", "password", "note", "totp", "vault"},
|
||||
"nordpass": {"name", "url", "username", "password", "note", "folder"},
|
||||
"roboform": {"Name", "Url", "MatchUrl", "Login", "Pwd", "Note", "Folder"},
|
||||
"safari": {"Title", "URL", "Username", "Password", "Notes", "OTPAuth"},
|
||||
"edge": {"name", "url", "username", "password", "note"},
|
||||
"brave": {"name", "url", "username", "password", "note"},
|
||||
}
|
||||
|
||||
for mgr, hdr := range wantHeader {
|
||||
m, err := NewCSV(mgr, "ignored")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: NewCSV: %v", mgr, err)
|
||||
}
|
||||
si, ok := m.(StagedImporter)
|
||||
if !ok {
|
||||
t.Fatalf("%s: not a StagedImporter", mgr)
|
||||
}
|
||||
path, cleanup, err := si.ImportStaged(v, accts, newPw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: ImportStaged: %v", mgr, err)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/dev/shm/") && !strings.Contains(path, "incredigo") {
|
||||
t.Errorf("%s: staged path %q not under a tmpfs/incredigo dir", mgr, path)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read staged: %v", mgr, err)
|
||||
}
|
||||
recs, err := csv.NewReader(strings.NewReader(string(raw))).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: parse staged: %v", mgr, err)
|
||||
}
|
||||
if len(recs) < 2 {
|
||||
t.Fatalf("%s: staged csv has no data row:\n%s", mgr, raw)
|
||||
}
|
||||
if strings.Join(recs[0], ",") != strings.Join(hdr, ",") {
|
||||
t.Errorf("%s: header = %v, want %v", mgr, recs[0], hdr)
|
||||
}
|
||||
row := strings.Join(recs[1], ",")
|
||||
if !strings.Contains(row, "NEW-PASS-1") {
|
||||
t.Errorf("%s: row missing new password:\n%s", mgr, row)
|
||||
}
|
||||
if strings.Contains(row, "OLD") {
|
||||
t.Errorf("%s: row must carry the NEW password, not OLD:\n%s", mgr, row)
|
||||
}
|
||||
if !strings.Contains(row, "GitHub") {
|
||||
t.Errorf("%s: row dropped the entry name:\n%s", mgr, row)
|
||||
}
|
||||
if !strings.Contains(row, "my note") {
|
||||
t.Errorf("%s: row dropped the note (round-trip would wipe it):\n%s", mgr, row)
|
||||
}
|
||||
cleanup()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: staged csv not shredded after cleanup: %v", mgr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnpassImportRefuses proves a manager with no CSV import format refuses ImportStaged
|
||||
// and points at the guided worklist, while still being readable for the guide.
|
||||
func TestEnpassImportRefuses(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
m, err := NewCSV("enpass", "ignored")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCSV enpass: %v", err)
|
||||
}
|
||||
si := m.(StagedImporter)
|
||||
if _, _, err := si.ImportStaged(v, []Account{{Secret: v.Store([]byte("x"))}}, nil); err == nil {
|
||||
t.Error("enpass ImportStaged must refuse (no CSV import format)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReimportHint proves the manager-specific re-import instruction is exposed.
|
||||
func TestReimportHint(t *testing.T) {
|
||||
m, _ := NewCSV("lastpass", "x")
|
||||
r, ok := m.(Reimporter)
|
||||
if !ok {
|
||||
t.Fatal("csv manager should implement Reimporter")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(r.ReimportHint()), "lastpass") {
|
||||
t.Errorf("reimport hint = %q, want a LastPass-specific instruction", r.ReimportHint())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCSVUnknownRejected proves an unknown name is rejected so the CLI can fall through.
|
||||
func TestNewCSVUnknownRejected(t *testing.T) {
|
||||
if _, err := NewCSV("nonsense", "x"); err == nil {
|
||||
t.Error("NewCSV must reject an unknown manager name")
|
||||
}
|
||||
if IsCSV("bitwarden") {
|
||||
t.Error("bitwarden is a CLI manager, not a CSV one")
|
||||
}
|
||||
if !IsCSV("lastpass") {
|
||||
t.Error("lastpass should be a CSV manager")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ type StagedImporter interface {
|
||||
ImportStaged(v *vault.Vault, accts []Account, newPw map[int]*vault.Handle) (path string, cleanup func(), err error)
|
||||
}
|
||||
|
||||
// Reimporter is optionally implemented by StagedImporters to provide a manager-specific
|
||||
// instruction telling the human exactly where to re-import the staged CSV (e.g. a browser
|
||||
// settings URL or a manager's import menu path). The commit path uses it when present.
|
||||
type Reimporter interface {
|
||||
ReimportHint() string
|
||||
}
|
||||
|
||||
// stagedRecord is one line of a sealed staged list. It carries the manager-native id
|
||||
// (so a headless commit can target the right item) plus the non-secret labels and the
|
||||
// password. The password is plaintext ONLY inside this struct in RAM; on disk this
|
||||
|
||||
Reference in New Issue
Block a user