From a2a8fcbd14a1566c6842d0e011bbe8a5e5644e54 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:04:38 -0700 Subject: [PATCH] Reconstruct KeePassGO repository --- .gitignore | 1 + .golangci.yml | 8 + AGENTS.md | 128 ++ README.md | 64 + TODO.md | 195 ++ api/server.go | 626 ++++++ api/server_test.go | 630 ++++++ appstate/state.go | 620 ++++++ appstate/state_test.go | 956 ++++++++ clipboard/service.go | 81 + clipboard/service_test.go | 78 + docs/desktop-automation.md | 34 + docs/kdbx-compatibility.md | 26 + go.mod | 210 ++ go.sum | 1024 +++++++++ main.go | 1122 ++++++++++ main_test.go | 530 +++++ passwords/generator.go | 168 ++ passwords/generator_test.go | 97 + proto/keepassgo/v1/keepassgo.pb.go | 2644 +++++++++++++++++++++++ proto/keepassgo/v1/keepassgo.proto | 229 ++ proto/keepassgo/v1/keepassgo_grpc.pb.go | 1033 +++++++++ session/session.go | 180 ++ session/session_test.go | 477 ++++ ui_editor.go | 337 +++ ui_forms.go | 168 ++ ui_shortcuts.go | 85 + vault/history_test.go | 160 ++ vault/kdbx.go | 550 +++++ vault/kdbx_test.go | 641 ++++++ vault/model.go | 449 ++++ vault/model_test.go | 292 +++ webdav/client.go | 93 + webdav/client_test.go | 105 + 34 files changed, 14041 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 TODO.md create mode 100644 api/server.go create mode 100644 api/server_test.go create mode 100644 appstate/state.go create mode 100644 appstate/state_test.go create mode 100644 clipboard/service.go create mode 100644 clipboard/service_test.go create mode 100644 docs/desktop-automation.md create mode 100644 docs/kdbx-compatibility.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 passwords/generator.go create mode 100644 passwords/generator_test.go create mode 100644 proto/keepassgo/v1/keepassgo.pb.go create mode 100644 proto/keepassgo/v1/keepassgo.proto create mode 100644 proto/keepassgo/v1/keepassgo_grpc.pb.go create mode 100644 session/session.go create mode 100644 session/session_test.go create mode 100644 ui_editor.go create mode 100644 ui_forms.go create mode 100644 ui_shortcuts.go create mode 100644 vault/history_test.go create mode 100644 vault/kdbx.go create mode 100644 vault/kdbx_test.go create mode 100644 vault/model.go create mode 100644 vault/model_test.go create mode 100644 webdav/client.go create mode 100644 webdav/client_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..147d7ee --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gio-keepass-mock diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..df12035 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,8 @@ +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a5e4af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# Product Requirements + +These instructions apply to all future work in this repository. + +## Product Intent + +- Build the product under the user-facing name `KeePassGO`. +- Build a real password manager product in Go. +- Keep Windows and Linux as first-class desktop targets. +- Keep Android as a future target, but do not distort the desktop product to chase Android prematurely. +- Preserve compatibility with KeePass/KDBX so the app can coexist with KeePass2Android. +- Use maintained upstream libraries for KDBX parsing, crypto, and other behavioral requirements when a maintained library is a good fit. + +## Skills + +- Use the installed Go skills whenever they materially apply to the current slice of work. +- The available Go skills for this repository are: + `go-code-review`, + `go-concurrency`, + `go-context`, + `go-control-flow`, + `go-data-structures`, + `go-declarations`, + `go-defensive`, + `go-documentation`, + `go-error-handling`, + `go-functional-options`, + `go-functions`, + `go-generics`, + `go-interfaces`, + `go-linting`, + `go-logging`, + `go-naming`, + `go-packages`, + `go-performance`, + `go-style-core`, + `go-testing`. +- In particular: + use `go-testing` for test-first slices and behavior tests, + `go-packages` for package boundaries and dependency layout, + `go-linting` for `golangci-lint`, + `go-code-review` when reviewing risky changes, + `go-interfaces` for seams and test doubles, + `go-error-handling` for error contracts, + `go-concurrency` and `go-context` for concurrent or cancellable flows, + and the remaining skills whenever their topic is directly implicated by the work. + +## Required Feature Parity Targets + +These are the KeePass 2.57.1 capabilities that this product should explicitly target, subject to platform constraints. + +- Database lifecycle: + create, open, edit, save, save-as, and lock/unlock KDBX databases. +- Master key support: + master password, key file, and composite master key support. +- KDBX compatibility: + preserve interoperability with KeePass/KeePass2Android-compatible databases. +- Database security settings: + support the major KeePass-style encryption and KDF configuration choices that are represented in KDBX databases. +- Groups and paths: + nested groups, entry paths, and path-aware navigation. +- Entry fields: + title, username, password, URL, notes, tags, and custom string fields. +- Search: + current-group listing, global search, and search results with visible path context. +- Entry data transfer: + copy username, copy password, copy URL, and password reveal/hide controls. +- Password generation: + strong password generation with configurable rules and reusable profiles. +- Entry history and recycle semantics: + support entry history and deletion/recovery behavior compatible with KeePass expectations. +- Attachments: + store and retrieve file attachments in entries. +- Templates: + support entry templates or an equivalent reusable-entry mechanism. +- Programmatic integration: + provide a secure gRPC API for trusted clients such as browser extensions and local automation. +- Desktop automation: + either provide a desktop login automation mechanism comparable in purpose to KeePass auto-type, or document why the secure gRPC integration surface supersedes it. +- Import/export: + KDBX import/export is mandatory; additional import/export formats are desirable but secondary. +- WebDAV workflow: + direct remote-file workflows must exist and be treated as a first-class product feature. +- Accessibility and keyboard use: + keyboard-first operation, high-DPI support, and screen-reader-conscious design. + +These features are product requirements, not “nice to have” ideas. + +## UX Direction + +- Desktop and phone layouts may differ significantly. +- Desktop should optimize for information density and low-friction navigation. +- Phone should optimize for low tap count, not purity of mobile patterns. +- The stacked phone layout is the current preferred phone direction. +- Do not reintroduce the abandoned phone flow mode unless explicitly requested. + +## Architecture + +- Separate domain logic from Gio UI code. +- UI state should not be the source of truth for vault structure or search behavior. +- Domain packages must be test-driven where practical. +- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details. +- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state. +- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols. + +## Delivery Discipline + +- Do not treat this product as complete until the stated requirements in this file are actually satisfied. +- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. +- Continue iterating in test-first slices: + add or extend behavior tests, + implement the minimum code to satisfy them, + verify with `go test ./...` and relevant lint checks, + and commit each completed behavior. +- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo. +- If blocked, state the blocker concretely and stop only at that point. + +## Persistence and Sync + +- Plan for direct KDBX support. +- Plan for direct WebDAV-based workflows. +- Avoid adding npm-based or browser-stack dependencies. + +## Tooling + +- Keep `golangci-lint` passing. +- Keep `go test ./...` passing. +- Do not commit generated binaries. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c2029f --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# KeePassGO + +KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support. + +## Current Capabilities + +- KDBX load and save +- password, key-file, and composite master-key support in the storage layer +- WebDAV-backed open and save support in the session layer +- password generation profiles +- gRPC integration surface for trusted automation +- template, attachment, group, history, and recycle-bin persistence + +## Run + +```bash +go run . +``` + +Phone-sized preview: + +```bash +go run . -mode phone +``` + +## Test + +```bash +go test ./... +go tool golangci-lint run ./... +``` + +KDBX security and KDF compatibility notes are documented in [`docs/kdbx-compatibility.md`](./docs/kdbx-compatibility.md). + +## Build + +Desktop build: + +```bash +go build ./... +``` + +## Android Packaging + +KeePassGO uses Gio, so Android packaging is done with `gogio`. + +Install: + +```bash +go install gioui.org/cmd/gogio@latest +``` + +Package: + +```bash +gogio -target android . +``` + +You will need the Android SDK and NDK installed and configured for real device or release packaging. + +## Automation + +Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type. +See [`docs/desktop-automation.md`](./docs/desktop-automation.md). diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..6850c13 --- /dev/null +++ b/TODO.md @@ -0,0 +1,195 @@ +# TODO + +## Single Completion Plan + +KeePassGO is not complete until every required capability in [`AGENTS.md`](./AGENTS.md) is implemented, verified, and integrated into the product. +This plan is intentionally a single plan with a single exit gate. It is not divided into phases or milestone buckets. + +## Remaining Work + +- Finish real application-state ownership. + - Keep application state as the single source of truth for: + - current session + - vault open or locked status + - current group path + - selected entry or template + - search query + - dirty state + - error and loading state + - Remove remaining direct UI-owned mutation of product state. + - Ensure all list, detail, breadcrumb, group, and selection behavior derives from the controller and session layers. + +- Finish local and remote database lifecycle UX. + - Add create new vault flow. + - Add open local vault flow. + - Add open remote WebDAV vault flow. + - Add save current vault. + - Add save-as local vault flow. + - Add lock and unlock flows. + - Add visible handling for: + - invalid master key + - unreadable file + - decode failure + - WebDAV conflict + - missing path or target + - Add dirty-state protection around destructive navigation. + +- Finish master-key and security configuration behavior. + - Add password-only setup. + - Add key-file-only setup. + - Add composite password plus key-file setup. + - Add UI and controller behavior for selecting or changing master-key mode. + - Preserve supported KDBX security and KDF settings when loading and saving. + - Document any unsupported settings explicitly. + +- Finish entry CRUD as a real product workflow. + - Add create entry. + - Add edit entry. + - Add duplicate entry. + - Add delete entry to recycle bin. + - Add restore entry from recycle bin. + - Add entry history browsing. + - Add restore historical version behavior. + - Add editing for: + - title + - username + - password + - URL + - notes + - tags + - custom fields + - Add reveal and hide password behavior in the actual product flow, not only the prototype view. + +- Finish template workflows. + - Add create template. + - Add edit template. + - Add delete template. + - Add template browsing UI. + - Add instantiate-template workflow with override support. + - Ensure template behavior is available through both UI and gRPC surfaces. + +- Finish group and path management. + - Add create group. + - Add rename group. + - Add delete group. + - Add move entry between groups. + - Add move template between groups if supported. + - Make breadcrumb and group navigation controller-driven throughout the product. + - Make templates and recycle-bin locations explicit and navigable. + +- Finish search behavior. + - Support current-group listing. + - Support global search. + - Keep visible path context in results. + - Define and implement search behavior for: + - templates + - recycle bin + - Add clear and reset behavior. + - Ensure search works consistently in desktop and phone layouts. + +- Finish data transfer behavior. + - Keep copy username, copy password, and copy URL available through the UI. + - Keep those behaviors available through gRPC. + - Add product behavior for clipboard feedback. + - Decide and implement timed clipboard clearing if used. + - Ensure errors and logs do not leak secret contents. + +- Finish attachments UX. + - Add attach file to entry. + - Add list attachments. + - Add export or download attachment. + - Add replace attachment. + - Add remove attachment. + - Add size and error handling. + - Add file selection abstraction appropriate for desktop and future Android support. + +- Finish password generation UX. + - Expose profile-based password generation in the UI. + - Allow generated passwords to flow directly into create and edit entry workflows. + - Keep generation behavior exposed through gRPC. + +- Finish gRPC as the first-class trusted integration surface. + - Add open, save, lock, and unlock RPCs. + - Add current session status RPC. + - Add group listing and group mutation RPCs. + - Add history listing and history restore RPCs. + - Add attachment listing, upload, and download RPCs. + - Add template CRUD RPCs where missing. + - Keep authentication and error contracts consistent across all methods. + - Keep the API independent of UI state. + +- Resolve the desktop automation requirement. + - Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type, + - or document, in-repo, that the secure gRPC interface supersedes it and why. + - The decision must be explicit and committed. + +- Finish accessibility and keyboard-first behavior. + - Add keyboard navigation across: + - list + - detail + - search + - breadcrumbs + - dialogs + - Add keyboard shortcuts for: + - search + - save + - lock + - create entry + - copy username + - copy password + - copy URL + - Add visible focus states. + - Improve screen-reader-conscious labeling where the toolkit allows it. + - Verify high-DPI behavior. + +- Finish UI completion and polish. + - Replace remaining prototype-only behavior. + - Add empty states. + - Add loading states. + - Add error states. + - Add recycle-bin view. + - Add template view. + - Add lock screen. + - Add master-key prompt screens. + - Add save-conflict surfaces. + - Keep desktop information-dense. + - Keep phone layout optimized for low tap count. + +- Finish packaging and runnable-product shape. + - Keep the desktop app runnable from the repo. + - Add documented build and run instructions. + - Add packaging guidance for desktop release builds. + - Add Android packaging guidance with `gogio`. + - Add icon and application metadata placeholders. + +- Finish integration and regression coverage. + - Add controller and UI behavior tests for completed workflows. + - Add gRPC integration tests for lifecycle and mutation flows. + - Add WebDAV conflict and reload coverage. + - Add attachment workflow coverage. + - Add history and recycle-bin integration coverage. + - Add regression coverage for stable entry IDs across reopen and remote save cycles. + +## Exit Criteria + +Do not stop until all of the following are true: + +- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI. +- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible handling of conflict and error states. +- KeePassGO supports master password, key file, and composite key workflows in the product, not just in storage helpers. +- KeePassGO preserves supported KDBX security and KDF settings and documents any unsupported settings. +- KeePassGO supports nested groups, path-aware navigation, and explicit template and recycle-bin navigation. +- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI. +- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI. +- KeePassGO supports attachment add, remove, replace, list, and export through the UI. +- KeePassGO supports reusable templates through the UI and through the gRPC API. +- KeePassGO supports current-group listing, global search, and visible path context consistently across desktop and phone layouts. +- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end. +- KeePassGO exposes password generation profiles through both UI and gRPC workflows. +- The secure gRPC API is broad enough for trusted automation and browser-extension style integration, including lifecycle and mutation operations. +- The desktop automation requirement is explicitly resolved, either by implementation or committed justification that gRPC supersedes it. +- Keyboard-first navigation and common shortcuts exist for the major product workflows. +- The UI no longer depends on prototype-only mock behavior for any core workflow. +- Build and run instructions exist for desktop, and packaging guidance exists for Android. +- `go test ./...` passes. +- `go tool golangci-lint run ./...` passes. diff --git a/api/server.go b/api/server.go new file mode 100644 index 0000000..157cec5 --- /dev/null +++ b/api/server.go @@ -0,0 +1,626 @@ +package api + +import ( + "context" + "errors" + "maps" + "slices" + "sync" + "strings" + + "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/passwords" + keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" + "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/vault" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +type Server struct { + keepassgov1.UnimplementedVaultServiceServer + + mu sync.RWMutex + model vault.Model + locked bool + dirty bool + lifecycle lifecycleBackend + profiles map[string]passwords.Profile + clipboard clipboard.Writer +} + +type lifecycleBackend interface { + Current() (vault.Model, error) + Open(string, vault.MasterKey) error + OpenRemote(webdav.Client, string, vault.MasterKey) error + Save() error +} + +func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server { + return &Server{ + model: model, + profiles: profiles, + clipboard: clipboardWriter, + } +} + +func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server { + server := NewServer(model, profiles, clipboardWriter) + server.lifecycle = lifecycle + return server +} + +func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return &keepassgov1.GetSessionStatusResponse{ + Locked: s.locked, + Dirty: s.dirty, + EntryCount: uint32(len(s.model.Entries)), + }, nil +} + +func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) { + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + + key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} + if err := s.lifecycle.Open(req.GetPath(), key); err != nil { + return nil, status.Errorf(codes.Internal, "open vault: %v", err) + } + + model, err := s.lifecycle.Current() + if err != nil { + return nil, status.Errorf(codes.Internal, "load opened vault: %v", err) + } + + s.mu.Lock() + s.model = model + s.locked = false + s.dirty = false + s.mu.Unlock() + + return &keepassgov1.OpenVaultResponse{}, nil +} + +func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) { + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + + client := webdav.Client{ + BaseURL: req.GetBaseUrl(), + Username: req.GetUsername(), + Password: req.GetPassword(), + } + key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} + if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil { + return nil, status.Errorf(codes.Internal, "open remote vault: %v", err) + } + + model, err := s.lifecycle.Current() + if err != nil { + return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err) + } + + s.mu.Lock() + s.model = model + s.locked = false + s.dirty = false + s.mu.Unlock() + + return &keepassgov1.OpenRemoteVaultResponse{}, nil +} + +func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) { + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + + if err := s.lifecycle.Save(); err != nil { + return nil, status.Errorf(codes.Internal, "save vault: %v", err) + } + + s.mu.Lock() + s.dirty = false + s.mu.Unlock() + + return &keepassgov1.SaveVaultResponse{}, nil +} + +func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.locked = true + + return &keepassgov1.LockVaultResponse{}, nil +} + +func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.locked = false + + return &keepassgov1.UnlockVaultResponse{}, nil +} + +func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + var entries []vault.Entry + if strings.TrimSpace(req.GetQuery()) != "" { + results := s.model.Search(req.GetQuery()) + entries = make([]vault.Entry, 0, len(results)) + for _, result := range results { + entries = append(entries, result.Entry) + } + } else { + entries = s.model.EntriesInPath(req.GetPath()) + } + + resp := &keepassgov1.ListEntriesResponse{ + Entries: make([]*keepassgov1.Entry, 0, len(entries)), + } + for _, entry := range entries { + resp.Entries = append(resp.Entries, entryToProto(entry)) + } + + return resp, nil +} + +func (s *Server) ListGroups(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + return &keepassgov1.ListGroupsResponse{ + Names: s.model.ChildGroups(req.GetPath()), + }, nil +} + +func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + s.model.CreateGroup(req.GetParentPath(), req.GetName()) + s.dirty = true + return &keepassgov1.CreateGroupResponse{}, nil +} + +func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "rename group: %v", err) + } + + s.dirty = true + return &keepassgov1.RenameGroupResponse{}, nil +} + +func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) { + if req.GetEntry() == nil { + return nil, status.Error(codes.InvalidArgument, "missing entry") + } + + entry := entryFromProto(req.GetEntry()) + + s.mu.Lock() + if s.locked { + s.mu.Unlock() + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + s.model.UpsertEntry(entry) + s.dirty = true + s.mu.Unlock() + + return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil +} + +func (s *Server) DeleteEntry(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + if err := s.model.DeleteEntry(req.GetId()); err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "delete entry: %v", err) + } + + s.dirty = true + return &keepassgov1.DeleteEntryResponse{}, nil +} + +func (s *Server) RestoreEntry(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + var restored vault.Entry + for _, entry := range s.model.RecycleBin { + if entry.ID == req.GetId() { + restored = entry + break + } + } + + if err := s.model.RestoreEntry(req.GetId()); err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "restore entry: %v", err) + } + + s.dirty = true + return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil +} + +func (s *Server) ListEntryHistory(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, err := findEntryByID(s.model, req.GetId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + resp := &keepassgov1.ListEntryHistoryResponse{ + Entries: make([]*keepassgov1.Entry, 0, len(entry.History)), + } + for _, historical := range entry.History { + resp.Entries = append(resp.Entries, entryToProto(historical)) + } + return resp, nil +} + +func (s *Server) RestoreEntryHistory(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "restore entry history: %v", err) + } + + entry, err := findEntryByID(s.model, req.GetId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + s.dirty = true + return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil +} + +func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + resp := &keepassgov1.ListTemplatesResponse{ + Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)), + } + for _, template := range s.model.Templates { + resp.Templates = append(resp.Templates, entryToProto(template)) + } + + return resp, nil +} + +func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) { + if req.GetTemplate() == nil { + return nil, status.Error(codes.InvalidArgument, "missing template") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry := entryFromProto(req.GetTemplate()) + s.model.UpsertTemplate(entry) + s.dirty = true + + return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil +} + +func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + if err := s.model.DeleteTemplate(req.GetId()); err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "delete template: %v", err) + } + s.dirty = true + + return &keepassgov1.DeleteTemplateResponse{}, nil +} + +func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) { + if req.GetOverrides() == nil { + return nil, status.Error(codes.InvalidArgument, "missing overrides") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides())) + if err != nil { + if errors.Is(err, vault.ErrEntryNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Errorf(codes.Internal, "instantiate template: %v", err) + } + + s.dirty = true + return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil +} + +func (s *Server) ListAttachments(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, err := findEntryByID(s.model, req.GetEntryId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + names := make([]string, 0, len(entry.Attachments)) + for name := range entry.Attachments { + names = append(names, name) + } + slices.Sort(names) + + return &keepassgov1.ListAttachmentsResponse{Names: names}, nil +} + +func (s *Server) UploadAttachment(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + if entry.Attachments == nil { + entry.Attachments = map[string][]byte{} + } + entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...) + s.model.Entries[index] = entry + s.dirty = true + + return &keepassgov1.UploadAttachmentResponse{}, nil +} + +func (s *Server) DownloadAttachment(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, err := findEntryByID(s.model, req.GetEntryId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + content, ok := entry.Attachments[req.GetName()] + if !ok { + return nil, status.Error(codes.NotFound, "attachment not found") + } + + return &keepassgov1.DownloadAttachmentResponse{ + Content: append([]byte(nil), content...), + }, nil +} + +func (s *Server) DeleteAttachment(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId()) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + if _, ok := entry.Attachments[req.GetName()]; !ok { + return nil, status.Error(codes.NotFound, "attachment not found") + } + + delete(entry.Attachments, req.GetName()) + if len(entry.Attachments) == 0 { + entry.Attachments = nil + } + s.model.Entries[index] = entry + s.dirty = true + + return &keepassgov1.DeleteAttachmentResponse{}, nil +} + +func (s *Server) CopyEntryField(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) { + s.mu.RLock() + model := s.model + locked := s.locked + s.mu.RUnlock() + + if locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + service := clipboard.Service{Writer: s.clipboard} + if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil { + switch { + case errors.Is(err, vault.ErrEntryNotFound): + return nil, status.Error(codes.NotFound, err.Error()) + case errors.Is(err, clipboard.ErrUnsupportedTarget): + return nil, status.Error(codes.InvalidArgument, err.Error()) + default: + return nil, status.Errorf(codes.Internal, "copy entry field: %v", err) + } + } + + return &keepassgov1.CopyEntryFieldResponse{}, nil +} + +func (s *Server) GeneratePassword(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + profile, ok := s.profiles[req.GetProfile()] + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile()) + } + + password, err := passwords.Generate(profile) + if err != nil { + return nil, status.Errorf(codes.Internal, "generate password: %v", err) + } + + return &keepassgov1.GeneratePasswordResponse{Password: password}, nil +} + +func entryToProto(entry vault.Entry) *keepassgov1.Entry { + return &keepassgov1.Entry{ + Id: entry.ID, + Title: entry.Title, + Username: entry.Username, + Password: entry.Password, + Url: entry.URL, + Notes: entry.Notes, + Tags: append([]string(nil), entry.Tags...), + Path: append([]string(nil), entry.Path...), + } +} + +func entryFromProto(entry *keepassgov1.Entry) vault.Entry { + return vault.Entry{ + ID: entry.GetId(), + Title: entry.GetTitle(), + Username: entry.GetUsername(), + Password: entry.GetPassword(), + URL: entry.GetUrl(), + Notes: entry.GetNotes(), + Tags: append([]string(nil), entry.GetTags()...), + Path: append([]string(nil), entry.GetPath()...), + } +} + +func findEntryByID(model vault.Model, id string) (vault.Entry, error) { + for _, entry := range model.Entries { + if entry.ID == id { + return entry, nil + } + } + return vault.Entry{}, vault.ErrEntryNotFound +} + +func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) { + for i, entry := range model.Entries { + if entry.ID == id { + entry.Attachments = maps.Clone(entry.Attachments) + return entry, i, nil + } + } + return vault.Entry{}, -1, vault.ErrEntryNotFound +} + +func BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor { + return func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Error(codes.Unauthenticated, "missing metadata") + } + + values := md.Get("authorization") + if len(values) == 0 { + return nil, status.Error(codes.Unauthenticated, "missing authorization") + } + + if values[0] != "Bearer "+expectedToken { + return nil, status.Error(codes.Unauthenticated, "invalid bearer token") + } + + return handler(ctx, req) + } +} diff --git a/api/server_test.go b/api/server_test.go new file mode 100644 index 0000000..d12494c --- /dev/null +++ b/api/server_test.go @@ -0,0 +1,630 @@ +package api + +import ( + "bytes" + "context" + "net" + "testing" + + "git.julianfamily.org/keepassgo/passwords" + keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + _, err := client.ListEntries(context.Background(), &keepassgov1.ListEntriesRequest{}) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("ListEntries() code = %v, want %v", status.Code(err), codes.Unauthenticated) + } +} + +func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() error = %v", err) + } + if statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = true, want false at startup") + } + if statusResp.EntryCount == 0 { + t.Fatal("GetSessionStatus().EntryCount = 0, want non-zero") + } + + if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil { + t.Fatalf("LockVault() error = %v", err) + } + + statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after lock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after lock") + } + + if _, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("ListEntries() code = %v, want FailedPrecondition while locked", status.Code(err)) + } + + if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}); err != nil { + t.Fatalf("UnlockVault() error = %v", err) + } + + statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after unlock error = %v", err) + } + if statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = true, want false after unlock") + } +} + +func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { + t.Parallel() + + lifecycle := &stubLifecycle{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}}, + }, + }, + } + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ + Path: "/tmp/test.kdbx", + Password: "correct horse battery staple", + }); err != nil { + t.Fatalf("OpenVault() error = %v", err) + } + if lifecycle.openPath != "/tmp/test.kdbx" { + t.Fatalf("openPath = %q, want /tmp/test.kdbx", lifecycle.openPath) + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() after open error = %v", err) + } + if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" { + t.Fatalf("ListEntries().Entries = %#v, want Remote Git after open", listed.Entries) + } + + if _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}); err != nil { + t.Fatalf("SaveVault() error = %v", err) + } + if !lifecycle.saved { + t.Fatal("SaveVault() did not call lifecycle Save") + } + + if _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ + BaseUrl: "https://dav.example.com", + Path: "vaults/main.kdbx", + Username: "rustyryan", + Password: "dav-token", + MasterPassword: "correct horse battery staple", + }); err != nil { + t.Fatalf("OpenRemoteVault() error = %v", err) + } + if lifecycle.remoteBaseURL != "https://dav.example.com" || lifecycle.remotePath != "vaults/main.kdbx" { + t.Fatalf("remote open = %q %q, want dav.example.com vaults/main.kdbx", lifecycle.remoteBaseURL, lifecycle.remotePath) + } +} + +func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + resp, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + if len(resp.Entries) != 1 { + t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries)) + } + + if resp.Entries[0].Title != "Vault Console" { + t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console") + } +} + +func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + + listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) + if err != nil { + t.Fatalf("ListGroups() error = %v", err) + } + if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" { + t.Fatalf("ListGroups().Names = %#v, want [Home Assistant Internet]", listed.Names) + } + + if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{ + ParentPath: []string{"Root"}, + Name: "Finance", + }); err != nil { + t.Fatalf("CreateGroup() error = %v", err) + } + + listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) + if err != nil { + t.Fatalf("ListGroups() error = %v", err) + } + if len(listed.Names) != 3 || listed.Names[0] != "Finance" { + t.Fatalf("ListGroups().Names = %#v, want Finance present after create", listed.Names) + } + + if _, err := client.RenameGroup(ctx, &keepassgov1.RenameGroupRequest{ + Path: []string{"Root", "Internet"}, + NewName: "Infra", + }); err != nil { + t.Fatalf("RenameGroup() error = %v", err) + } + + listed, err = client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) + if err != nil { + t.Fatalf("ListGroups() error = %v", err) + } + if len(listed.Names) != 3 || listed.Names[2] != "Infra" { + t.Fatalf("ListGroups().Names = %#v, want Infra after rename", listed.Names) + } +} + +func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + resp, err := client.GeneratePassword(ctx, &keepassgov1.GeneratePasswordRequest{Profile: "strong"}) + if err != nil { + t.Fatalf("GeneratePassword() error = %v", err) + } + + if len(resp.Password) < passwords.DefaultProfiles()["strong"].Length { + t.Fatalf("len(GeneratePassword().Password) = %d, want at least %d", len(resp.Password), passwords.DefaultProfiles()["strong"].Length) + } +} + +func TestVaultServiceCopiesEntryFieldsForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, clipboardWriter, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.CopyEntryField(ctx, &keepassgov1.CopyEntryFieldRequest{ + Id: "vault-console", + Target: "password", + }); err != nil { + t.Fatalf("CopyEntryField() error = %v", err) + } + + if clipboardWriter.content != "token-1" { + t.Fatalf("clipboard content = %q, want %q", clipboardWriter.content, "token-1") + } +} + +func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + upserted, err := client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{ + Entry: &keepassgov1.Entry{ + Id: "surveillance-console", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + Url: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }) + if err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if upserted.Entry.Title != "Surveillance Console" { + t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console") + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}}) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" { + t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries) + } +} + +func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.DeleteEntry(ctx, &keepassgov1.DeleteEntryRequest{Id: "vault-console"}); err != nil { + t.Fatalf("DeleteEntry() error = %v", err) + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + if len(listed.Entries) != 0 { + t.Fatalf("len(ListEntries().Entries) = %d, want 0 after delete", len(listed.Entries)) + } + + restored, err := client.RestoreEntry(ctx, &keepassgov1.RestoreEntryRequest{Id: "vault-console"}) + if err != nil { + t.Fatalf("RestoreEntry() error = %v", err) + } + + if restored.Entry.Title != "Vault Console" { + t.Fatalf("RestoreEntry().Entry.Title = %q, want %q", restored.Entry.Title, "Vault Console") + } + + listed, err = client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + if len(listed.Entries) != 1 || listed.Entries[0].Title != "Vault Console" { + t.Fatalf("ListEntries().Entries = %#v, want restored Vault Console entry", listed.Entries) + } +} + +func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + templates, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) + if err != nil { + t.Fatalf("ListTemplates() error = %v", err) + } + + if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" { + t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates) + } + + instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ + TemplateId: "website-login", + Overrides: &keepassgov1.Entry{ + Id: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Password: "hunter2", + Url: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + Tags: []string{"dns"}, + }, + }) + if err != nil { + t.Fatalf("InstantiateTemplate() error = %v", err) + } + + if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." { + t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry) + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() error = %v", err) + } + + if len(listed.Entries) != 2 { + t.Fatalf("len(ListEntries().Entries) = %d, want 2 after template instantiation", len(listed.Entries)) + } +} + +func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + upserted, err := client.UpsertTemplate(ctx, &keepassgov1.UpsertTemplateRequest{ + Template: &keepassgov1.Entry{ + Id: "website-login", + Title: "Website Login Updated", + Username: "template-user", + Password: "template-password", + Path: []string{"Templates", "Web"}, + }, + }) + if err != nil { + t.Fatalf("UpsertTemplate() error = %v", err) + } + if upserted.Template.Title != "Website Login Updated" { + t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title) + } + + listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) + if err != nil { + t.Fatalf("ListTemplates() error = %v", err) + } + if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" { + t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates) + } + + if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil { + t.Fatalf("DeleteTemplate() error = %v", err) + } + + listed, err = client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) + if err != nil { + t.Fatalf("ListTemplates() error = %v", err) + } + if len(listed.Templates) != 0 { + t.Fatalf("ListTemplates().Templates = %#v, want empty after delete", listed.Templates) + } +} + +func TestVaultServiceListsAndRestoresEntryHistoryForAuthorizedClients(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + history, err := client.ListEntryHistory(ctx, &keepassgov1.ListEntryHistoryRequest{Id: "vault-console"}) + if err != nil { + t.Fatalf("ListEntryHistory() error = %v", err) + } + if len(history.Entries) != 1 || history.Entries[0].Password != "token-0" { + t.Fatalf("ListEntryHistory().Entries = %#v, want old token entry", history.Entries) + } + + restored, err := client.RestoreEntryHistory(ctx, &keepassgov1.RestoreEntryHistoryRequest{ + Id: "vault-console", + HistoryIndex: 0, + }) + if err != nil { + t.Fatalf("RestoreEntryHistory() error = %v", err) + } + if restored.Entry.Password != "token-0" { + t.Fatalf("RestoreEntryHistory().Entry.Password = %q, want token-0", restored.Entry.Password) + } +} + +func TestVaultServiceListsUploadsDownloadsAndDeletesAttachments(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + uploaded := []byte("attachment-content") + + if _, err := client.UploadAttachment(ctx, &keepassgov1.UploadAttachmentRequest{ + EntryId: "vault-console", + Name: "token.txt", + Content: uploaded, + }); err != nil { + t.Fatalf("UploadAttachment() error = %v", err) + } + + listed, err := client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) + if err != nil { + t.Fatalf("ListAttachments() error = %v", err) + } + if len(listed.Names) != 1 || listed.Names[0] != "token.txt" { + t.Fatalf("ListAttachments().Names = %#v, want [token.txt]", listed.Names) + } + + downloaded, err := client.DownloadAttachment(ctx, &keepassgov1.DownloadAttachmentRequest{ + EntryId: "vault-console", + Name: "token.txt", + }) + if err != nil { + t.Fatalf("DownloadAttachment() error = %v", err) + } + if !bytes.Equal(downloaded.Content, uploaded) { + t.Fatalf("DownloadAttachment().Content = %q, want %q", downloaded.Content, uploaded) + } + + if _, err := client.DeleteAttachment(ctx, &keepassgov1.DeleteAttachmentRequest{ + EntryId: "vault-console", + Name: "token.txt", + }); err != nil { + t.Fatalf("DeleteAttachment() error = %v", err) + } + + listed, err = client.ListAttachments(ctx, &keepassgov1.ListAttachmentsRequest{EntryId: "vault-console"}) + if err != nil { + t.Fatalf("ListAttachments() error = %v", err) + } + if len(listed.Names) != 0 { + t.Fatalf("ListAttachments().Names = %#v, want empty after delete", listed.Names) + } +} + +func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { + t.Helper() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) + clipboardWriter := &memoryClipboardWriter{} + keepassgov1.RegisterVaultServiceServer(server, NewServer( + vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + History: []vault.Entry{ + { + ID: "vault-console-h1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-0", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + Path: []string{"Root", "Internet"}, + }, + { + ID: "surveillance-console", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + Templates: []vault.Entry{ + { + ID: "website-login", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Tags: []string{"template", "web"}, + Path: []string{"Templates"}, + }, + }, + }, + passwords.DefaultProfiles(), + clipboardWriter, + )) + + go func() { + _ = server.Serve(listener) + }() + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient() error = %v", err) + } + + cleanup := func() { + _ = conn.Close() + server.Stop() + } + + return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup +} + +func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepassgov1.VaultServiceClient, *memoryClipboardWriter, func()) { + t.Helper() + + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) + clipboardWriter := &memoryClipboardWriter{} + keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle( + vault.Model{}, + passwords.DefaultProfiles(), + clipboardWriter, + lifecycle, + )) + + go func() { + _ = server.Serve(listener) + }() + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient() error = %v", err) + } + + cleanup := func() { + _ = conn.Close() + server.Stop() + } + + return keepassgov1.NewVaultServiceClient(conn), clipboardWriter, cleanup +} + +type memoryClipboardWriter struct { + content string +} + +func (w *memoryClipboardWriter) WriteText(text string) error { + w.content = text + return nil +} + +type stubLifecycle struct { + model vault.Model + openPath string + remoteBaseURL string + remotePath string + saved bool + locked bool +} + +func (s *stubLifecycle) Current() (vault.Model, error) { + if s.locked { + return vault.Model{}, session.ErrLocked + } + return s.model, nil +} + +func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error { + s.openPath = path + return nil +} + +func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + s.remoteBaseURL = client.BaseURL + s.remotePath = path + return nil +} + +func (s *stubLifecycle) Save() error { + s.saved = true + return nil +} diff --git a/appstate/state.go b/appstate/state.go new file mode 100644 index 0000000..2df49bf --- /dev/null +++ b/appstate/state.go @@ -0,0 +1,620 @@ +package appstate + +import ( + "fmt" + "slices" + "strings" + + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" +) + +type Section string + +const ( + SectionEntries Section = "" + SectionTemplates Section = "templates" + SectionRecycleBin Section = "recycle-bin" +) + +type CurrentSession interface { + Current() (vault.Model, error) +} + +type MutableSession interface { + CurrentSession + Replace(vault.Model) +} + +type LockableSession interface { + CurrentSession + Lock() error + Unlock(vault.MasterKey) error +} + +type SaveableSession interface { + CurrentSession + Save() error +} + +type CreateableSession interface { + CurrentSession + Create(vault.Model, vault.MasterKey) error +} + +type OpenableSession interface { + CurrentSession + Open(string, vault.MasterKey) error +} + +type SaveAsSession interface { + CurrentSession + SaveAs(string) error +} + +type RemoteOpenableSession interface { + CurrentSession + OpenRemote(webdav.Client, string, vault.MasterKey) error +} + +type State struct { + Session CurrentSession + Section Section + CurrentPath []string + SearchQuery string + SelectedEntryID string + Dirty bool +} + +func (s *State) VisibleEntries() ([]vault.Entry, error) { + model, err := s.currentModel() + if err != nil { + return nil, err + } + + entries := s.entriesForSection(model) + if strings.TrimSpace(s.SearchQuery) != "" { + return filterEntries(entries, s.SearchQuery), nil + } + + if s.Section == SectionEntries { + return model.EntriesInPath(s.CurrentPath), nil + } + if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { + return entries, nil + } + + return entriesInPath(entries, s.CurrentPath), nil +} + +func (s *State) ChildGroups() ([]string, error) { + if strings.TrimSpace(s.SearchQuery) != "" { + return nil, nil + } + + model, err := s.currentModel() + if err != nil { + return nil, err + } + + if s.Section != SectionEntries { + if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { + return childGroups(s.entriesForSection(model), []string{"Templates"}), nil + } + return childGroups(s.entriesForSection(model), s.CurrentPath), nil + } + + return model.ChildGroups(s.CurrentPath), nil +} + +func (s *State) SelectVisibleIndex(index int) error { + entries, err := s.VisibleEntries() + if err != nil { + return err + } + if index < 0 || index >= len(entries) { + return fmt.Errorf("visible index %d out of range", index) + } + + s.SelectedEntryID = entries[index].ID + return nil +} + +func (s *State) ToggleVisibleIndex(index int) error { + entries, err := s.VisibleEntries() + if err != nil { + return err + } + if index < 0 || index >= len(entries) { + return fmt.Errorf("visible index %d out of range", index) + } + + if s.SelectedEntryID == entries[index].ID { + s.SelectedEntryID = "" + return nil + } + + s.SelectedEntryID = entries[index].ID + return nil +} + +func (s *State) currentModel() (vault.Model, error) { + if s.Session == nil { + return vault.Model{}, nil + } + return s.Session.Current() +} + +func (s *State) entriesForSection(model vault.Model) []vault.Entry { + switch s.Section { + case SectionTemplates: + return slices.Clone(model.Templates) + case SectionRecycleBin: + return slices.Clone(model.RecycleBin) + default: + return slices.Clone(model.Entries) + } +} + +func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { + var out []vault.Entry + for _, entry := range entries { + if slices.Equal(entry.Path, path) { + out = append(out, entry) + } + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + +func filterEntries(entries []vault.Entry, query string) []vault.Entry { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return nil + } + + var out []vault.Entry + for _, entry := range entries { + haystack := strings.ToLower( + entry.Title + " " + + entry.Username + " " + + entry.URL + " " + + strings.Join(entry.Path, " "), + ) + if !strings.Contains(haystack, query) { + continue + } + out = append(out, entry) + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + +func childGroups(entries []vault.Entry, path []string) []string { + seen := map[string]bool{} + var groups []string + for _, entry := range entries { + if len(path) > len(entry.Path) { + continue + } + if !slices.Equal(entry.Path[:len(path)], path) { + continue + } + if len(entry.Path) == len(path) { + continue + } + group := entry.Path[len(path)] + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + slices.Sort(groups) + return groups +} + +func (s *State) DeleteSelectedEntry() error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.DeleteEntry(s.SelectedEntryID); err != nil { + return err + } + + session.Replace(model) + s.SelectedEntryID = "" + s.Dirty = true + return nil +} + +func (s *State) RestoreEntry(id string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.RestoreEntry(id); err != nil { + return err + } + + session.Replace(model) + s.Dirty = true + return nil +} + +func (s *State) UpsertEntry(entry vault.Entry) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + model.UpsertEntry(entry) + session.Replace(model) + s.SelectedEntryID = entry.ID + s.Dirty = true + return nil +} + +func (s *State) UpsertTemplate(entry vault.Entry) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + model.UpsertTemplate(entry) + session.Replace(model) + s.SelectedEntryID = entry.ID + s.Dirty = true + return nil +} + +func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) { + session, ok := s.Session.(MutableSession) + if !ok { + return vault.Entry{}, fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return vault.Entry{}, err + } + + entry, err := model.InstantiateTemplate(templateID, overrides) + if err != nil { + return vault.Entry{}, err + } + + session.Replace(model) + s.SelectedEntryID = entry.ID + s.Dirty = true + return entry, nil +} + +func (s *State) DeleteTemplate(id string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.DeleteTemplate(id); err != nil { + return err + } + + session.Replace(model) + if s.SelectedEntryID == id { + s.SelectedEntryID = "" + } + s.Dirty = true + return nil +} + +func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) { + session, ok := s.Session.(MutableSession) + if !ok { + return vault.Entry{}, fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return vault.Entry{}, err + } + + duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID) + if err != nil { + return vault.Entry{}, err + } + + session.Replace(model) + s.SelectedEntryID = duplicate.ID + s.Dirty = true + return duplicate, nil +} + +func (s *State) RestoreSelectedEntryVersion(historyIndex int) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil { + return err + } + + session.Replace(model) + s.Dirty = true + return nil +} + +func (s *State) Lock() error { + session, ok := s.Session.(LockableSession) + if !ok { + return fmt.Errorf("session is not lockable") + } + + if err := session.Lock(); err != nil { + return err + } + + s.SelectedEntryID = "" + return nil +} + +func (s *State) Unlock(key vault.MasterKey) error { + session, ok := s.Session.(LockableSession) + if !ok { + return fmt.Errorf("session is not lockable") + } + + return session.Unlock(key) +} + +func (s *State) EnterGroup(name string) { + s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name) + s.SelectedEntryID = "" +} + +func (s *State) NavigateToPath(path []string) { + s.CurrentPath = append([]string(nil), path...) + s.SelectedEntryID = "" +} + +func (s *State) Save() error { + session, ok := s.Session.(SaveableSession) + if !ok { + return fmt.Errorf("session is not saveable") + } + + if err := session.Save(); err != nil { + return err + } + + s.Dirty = false + return nil +} + +func (s *State) CreateVault(key vault.MasterKey) error { + session, ok := s.Session.(CreateableSession) + if !ok { + return fmt.Errorf("session is not createable") + } + + if err := session.Create(vault.Model{}, key); err != nil { + return err + } + + s.CurrentPath = nil + s.SelectedEntryID = "" + s.Dirty = false + return nil +} + +func (s *State) OpenVault(path string, key vault.MasterKey) error { + session, ok := s.Session.(OpenableSession) + if !ok { + return fmt.Errorf("session is not openable") + } + + if err := session.Open(path, key); err != nil { + return err + } + + s.CurrentPath = nil + s.SelectedEntryID = "" + s.Dirty = false + return nil +} + +func (s *State) SaveAs(path string) error { + session, ok := s.Session.(SaveAsSession) + if !ok { + return fmt.Errorf("session is not save-as capable") + } + + if err := session.SaveAs(path); err != nil { + return err + } + + s.Dirty = false + return nil +} + +func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error { + session, ok := s.Session.(RemoteOpenableSession) + if !ok { + return fmt.Errorf("session is not remote-openable") + } + + if err := session.OpenRemote(client, path, key); err != nil { + return err + } + + s.CurrentPath = nil + s.SelectedEntryID = "" + s.Dirty = false + return nil +} + +func (s *State) CreateGroup(name string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + model.CreateGroup(s.CurrentPath, name) + session.Replace(model) + s.Dirty = true + return nil +} + +func (s *State) RenameCurrentGroup(newName string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.RenameGroup(s.CurrentPath, newName); err != nil { + return err + } + + session.Replace(model) + if len(s.CurrentPath) > 0 { + s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName) + } + s.Dirty = true + return nil +} + +func (s *State) MoveSelectedEntry(path []string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { + return err + } + + session.Replace(model) + s.Dirty = true + return nil +} + +func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + for i := range model.Entries { + if model.Entries[i].ID != s.SelectedEntryID { + continue + } + if model.Entries[i].Attachments == nil { + model.Entries[i].Attachments = map[string][]byte{} + } + model.Entries[i].Attachments[name] = append([]byte(nil), content...) + session.Replace(model) + s.Dirty = true + return nil + } + + return vault.ErrEntryNotFound +} + +func (s *State) DeleteAttachmentFromSelectedEntry(name string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + for i := range model.Entries { + if model.Entries[i].ID != s.SelectedEntryID { + continue + } + delete(model.Entries[i].Attachments, name) + if len(model.Entries[i].Attachments) == 0 { + model.Entries[i].Attachments = nil + } + session.Replace(model) + s.Dirty = true + return nil + } + + return vault.ErrEntryNotFound +} diff --git a/appstate/state_test.go b/appstate/state_test.go new file mode 100644 index 0000000..7f678fa --- /dev/null +++ b/appstate/state_test.go @@ -0,0 +1,956 @@ +package appstate + +import ( + "errors" + "slices" + "testing" + + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" +) + +func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + }, + }, + }, + CurrentPath: []string{"Crew", "Internet"}, + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + titles := make([]string, 0, len(got)) + for _, entry := range got { + titles = append(titles, entry.Title) + } + + if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) { + t.Fatalf("visible titles = %v, want [Bellagio Vault Console]", titles) + } +} + +func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + }, + }, + }, + CurrentPath: []string{"Crew", "Internet"}, + SearchQuery: "surveillance", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].Title != "Surveillance Console" { + t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got) + } +} + +func TestVisibleEntriesUsesTemplateSection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(VisibleEntries()) = %d, want 2 templates", len(got)) + } +} + +func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + RecycleBin: []vault.Entry{ + {ID: "entry-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}}, + }, + }, + }, + Section: SectionRecycleBin, + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "entry-1" { + t.Fatalf("VisibleEntries() = %#v, want recycle-bin entry", got) + } +} + +func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, + }, + }, + }, + CurrentPath: []string{"Crew"}, + } + + got, err := state.ChildGroups() + if err != nil { + t.Fatalf("ChildGroups() error = %v", err) + } + + if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { + t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + } +} + +func TestChildGroupsUsesTemplateSectionPaths(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates"}, + } + + got, err := state.ChildGroups() + if err != nil { + t.Fatalf("ChildGroups() error = %v", err) + } + + if !slices.Equal(got, []string{"Infra", "Web"}) { + t.Fatalf("ChildGroups() = %v, want [Infra Web]", got) + } +} + +func TestSelectVisibleEntryAndToggleSelection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, + }, + }, + }, + CurrentPath: []string{"Crew", "Internet"}, + } + + if err := state.SelectVisibleIndex(1); err != nil { + t.Fatalf("SelectVisibleIndex() error = %v", err) + } + if got := state.SelectedEntryID; got != "vault-console" { + t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") + } + + if err := state.ToggleVisibleIndex(1); err != nil { + t.Fatalf("ToggleVisibleIndex() error = %v", err) + } + if got := state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID after toggle = %q, want empty", got) + } +} + +func TestVisibleEntriesFailsWhenVaultIsLocked(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{err: session.ErrLocked}, + } + + _, err := state.VisibleEntries() + if !errors.Is(err, session.ErrLocked) { + t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err) + } +} + +type stubSession struct { + model vault.Model + err error +} + +func (s stubSession) Current() (vault.Model, error) { + if s.err != nil { + return vault.Model{}, s.err + } + return s.model, nil +} + +func TestDeleteSelectedEntryUpdatesSessionAndClearsSelection(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + } + + if err := state.DeleteSelectedEntry(); err != nil { + t.Fatalf("DeleteSelectedEntry() error = %v", err) + } + + if got := state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID = %q, want empty", got) + } + + if len(sess.model.Entries) != 0 { + t.Fatalf("len(Entries) = %d, want 0", len(sess.model.Entries)) + } + + if len(sess.model.RecycleBin) != 1 || sess.model.RecycleBin[0].ID != "vault-console" { + t.Fatalf("RecycleBin = %#v, want vault-console entry", sess.model.RecycleBin) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after delete") + } +} + +func TestRestoreEntryMovesEntryBackIntoVisibleEntries(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{ + model: vault.Model{ + RecycleBin: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + } + + if err := state.RestoreEntry("vault-console"); err != nil { + t.Fatalf("RestoreEntry() error = %v", err) + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "vault-console" { + t.Fatalf("VisibleEntries() = %#v, want restored vault-console entry", got) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after restore") + } +} + +func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{ + model: vault.Model{}, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + } + + entry := vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + } + if err := state.UpsertEntry(entry); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if got := state.SelectedEntryID; got != "vault-console" { + t.Fatalf("SelectedEntryID = %q, want %q", got, "vault-console") + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if len(got) != 1 || got[0].Password != "token-1" { + t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after upsert") + } +} + +func TestInstantiateTemplateCreatesEntryAndSelectsIt(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + { + ID: "website-login", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Path: []string{"Templates"}, + }, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + } + + entry, err := state.InstantiateTemplate("website-login", vault.Entry{ + ID: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Password: "hunter2", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }) + if err != nil { + t.Fatalf("InstantiateTemplate() error = %v", err) + } + + if entry.Notes != "Reusable template for website accounts." { + t.Fatalf("entry.Notes = %q, want template notes", entry.Notes) + } + + if got := state.SelectedEntryID; got != "bellagio" { + t.Fatalf("SelectedEntryID = %q, want %q", got, "bellagio") + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if len(got) != 1 || got[0].ID != "bellagio" { + t.Fatalf("VisibleEntries() = %#v, want instantiated bellagio entry", got) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after template instantiation") + } +} + +func TestUpsertTemplateCreatesTemplateAndMarksStateDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{}} + state := State{Session: sess} + + if err := state.UpsertTemplate(vault.Entry{ + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Path: []string{"Templates"}, + }); err != nil { + t.Fatalf("UpsertTemplate() error = %v", err) + } + + if len(sess.model.Templates) != 1 || sess.model.Templates[0].ID != "tpl-1" { + t.Fatalf("Templates = %#v, want tpl-1 template", sess.model.Templates) + } + if state.SelectedEntryID != "tpl-1" { + t.Fatalf("SelectedEntryID = %q, want tpl-1 after template upsert", state.SelectedEntryID) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after template upsert") + } +} + +func TestDeleteTemplateRemovesTemplateAndClearsSelection(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, + }, + }} + state := State{ + Session: sess, + SelectedEntryID: "tpl-1", + } + + if err := state.DeleteTemplate("tpl-1"); err != nil { + t.Fatalf("DeleteTemplate() error = %v", err) + } + + if len(sess.model.Templates) != 0 { + t.Fatalf("Templates = %#v, want empty after delete", sess.model.Templates) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty after delete", state.SelectedEntryID) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after template delete") + } +} + +func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }} + state := State{ + Session: sess, + SelectedEntryID: "vault-console", + } + + duplicate, err := state.DuplicateSelectedEntry("vault-console-copy") + if err != nil { + t.Fatalf("DuplicateSelectedEntry() error = %v", err) + } + + if duplicate.ID != "vault-console-copy" { + t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "vault-console-copy") + } + if state.SelectedEntryID != "vault-console-copy" { + t.Fatalf("SelectedEntryID = %q, want vault-console-copy", state.SelectedEntryID) + } + if len(sess.model.Entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2 after duplicate", len(sess.model.Entries)) + } +} + +func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Password: "new-token", + Path: []string{"Root", "Internet"}, + History: []vault.Entry{ + {ID: "vault-console-h1", Title: "Vault Console", Password: "old-token", Path: []string{"Root", "Internet"}}, + }, + }, + }, + }} + state := State{ + Session: sess, + SelectedEntryID: "vault-console", + } + + if err := state.RestoreSelectedEntryVersion(0); err != nil { + t.Fatalf("RestoreSelectedEntryVersion() error = %v", err) + } + + if got := sess.model.Entries[0].Password; got != "old-token" { + t.Fatalf("Entries[0].Password = %q, want %q", got, "old-token") + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after history restore") + } +} + +func TestSaveClearsDirtyState(t *testing.T) { + t.Parallel() + + sess := &saveableStubSession{} + state := State{ + Session: sess, + Dirty: true, + } + + if err := state.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + if state.Dirty { + t.Fatal("Dirty = true, want false after save") + } + + if sess.saveCalls != 1 { + t.Fatalf("saveCalls = %d, want 1", sess.saveCalls) + } +} + +func TestCreateVaultResetsSelectionPathAndDirtyState(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + Dirty: true, + } + + if err := state.CreateVault(vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("CreateVault() error = %v", err) + } + + if sess.createCalls != 1 { + t.Fatalf("createCalls = %d, want 1", sess.createCalls) + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.Dirty { + t.Fatal("Dirty = true, want false after create") + } +} + +func TestOpenVaultResetsSelectionPathAndDirtyState(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + Dirty: true, + } + + if err := state.OpenVault("/tmp/test.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("OpenVault() error = %v", err) + } + + if sess.openPath != "/tmp/test.kdbx" { + t.Fatalf("openPath = %q, want %q", sess.openPath, "/tmp/test.kdbx") + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.Dirty { + t.Fatal("Dirty = true, want false after open") + } +} + +func TestSaveAsClearsDirtyState(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{} + state := State{ + Session: sess, + Dirty: true, + } + + if err := state.SaveAs("/tmp/other.kdbx"); err != nil { + t.Fatalf("SaveAs() error = %v", err) + } + + if sess.saveAsPath != "/tmp/other.kdbx" { + t.Fatalf("saveAsPath = %q, want %q", sess.saveAsPath, "/tmp/other.kdbx") + } + if state.Dirty { + t.Fatal("Dirty = true, want false after save-as") + } +} + +func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) { + t.Parallel() + + sess := &lifecycleStubSession{} + client := webdav.Client{BaseURL: "https://example.com"} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + Dirty: true, + } + + if err := state.OpenRemoteVault(client, "vaults/main.kdbx", vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("OpenRemoteVault() error = %v", err) + } + + if sess.remotePath != "vaults/main.kdbx" { + t.Fatalf("remotePath = %q, want %q", sess.remotePath, "vaults/main.kdbx") + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.Dirty { + t.Fatal("Dirty = true, want false after remote open") + } +} + +func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) { + t.Parallel() + + sess := &lockableStubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + } + + if err := state.Lock(); err != nil { + t.Fatalf("Lock() error = %v", err) + } + + if got := state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID = %q, want empty after lock", got) + } + + _, err := state.VisibleEntries() + if !errors.Is(err, session.ErrLocked) { + t.Fatalf("VisibleEntries() error = %v, want ErrLocked", err) + } +} + +func TestUnlockRestoresVaultVisibility(t *testing.T) { + t.Parallel() + + sess := &lockableStubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }, + locked: true, + } + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + } + + if err := state.Unlock(vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Unlock() error = %v", err) + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if len(got) != 1 || got[0].ID != "vault-console" { + t.Fatalf("VisibleEntries() = %#v, want vault-console entry after unlock", got) + } +} + +func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) { + t.Parallel() + + state := State{ + CurrentPath: []string{"Root"}, + SelectedEntryID: "vault-console", + } + + state.EnterGroup("Internet") + + if !slices.Equal(state.CurrentPath, []string{"Root", "Internet"}) { + t.Fatalf("CurrentPath = %v, want [Root Internet]", state.CurrentPath) + } + if got := state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID = %q, want empty", got) + } +} + +func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) { + t.Parallel() + + state := State{ + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + } + + state.NavigateToPath([]string{"Root", "Home Assistant"}) + + if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) { + t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath) + } + if got := state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID = %q, want empty", got) + } +} + +func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root"}, + } + + if err := state.CreateGroup("Finance"); err != nil { + t.Fatalf("CreateGroup() error = %v", err) + } + + got, err := state.ChildGroups() + if err != nil { + t.Fatalf("ChildGroups() error = %v", err) + } + + if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { + t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after CreateGroup") + } +} + +func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + } + + if err := state.RenameCurrentGroup("Infra"); err != nil { + t.Fatalf("RenameCurrentGroup() error = %v", err) + } + + if !slices.Equal(state.CurrentPath, []string{"Root", "Infra"}) { + t.Fatalf("CurrentPath = %v, want [Root Infra]", state.CurrentPath) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after RenameCurrentGroup") + } +} + +func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil { + t.Fatalf("MoveSelectedEntry() error = %v", err) + } + + state.NavigateToPath([]string{"Root", "Home Assistant"}) + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(VisibleEntries()) = %d, want 2", len(got)) + } + if got[0].ID != "bellagio" && got[1].ID != "bellagio" { + t.Fatalf("VisibleEntries() = %#v, want moved bellagio entry in destination group", got) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after MoveSelectedEntry") + } +} + +func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + if err := state.AddAttachmentToSelectedEntry("token.txt", []byte("secret")); err != nil { + t.Fatalf("AddAttachmentToSelectedEntry() error = %v", err) + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if string(got[0].Attachments["token.txt"]) != "secret" { + t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "secret") + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after AddAttachmentToSelectedEntry") + } +} + +func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) { + t.Parallel() + + model := testVaultModel() + model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} + sess := &mutableStubSession{model: model} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + if err := state.DeleteAttachmentFromSelectedEntry("token.txt"); err != nil { + t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v", err) + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if len(got[0].Attachments) != 0 { + t.Fatalf("attachments = %#v, want empty", got[0].Attachments) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after DeleteAttachmentFromSelectedEntry") + } +} + +type mutableStubSession struct { + model vault.Model + err error +} + +func (s *mutableStubSession) Current() (vault.Model, error) { + if s.err != nil { + return vault.Model{}, s.err + } + return s.model, nil +} + +func (s *mutableStubSession) Replace(model vault.Model) { + s.model = model +} + +func testVaultModel() vault.Model { + return vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}}, + }, + } +} + +type lockableStubSession struct { + model vault.Model + locked bool +} + +func (s *lockableStubSession) Current() (vault.Model, error) { + if s.locked { + return vault.Model{}, session.ErrLocked + } + return s.model, nil +} + +func (s *lockableStubSession) Lock() error { + s.locked = true + return nil +} + +func (s *lockableStubSession) Unlock(vault.MasterKey) error { + s.locked = false + return nil +} + +type saveableStubSession struct { + saveCalls int +} + +func (s *saveableStubSession) Current() (vault.Model, error) { + return vault.Model{}, nil +} + +func (s *saveableStubSession) Save() error { + s.saveCalls++ + return nil +} + +type lifecycleStubSession struct { + createCalls int + openPath string + saveAsPath string + remotePath string +} + +func (s *lifecycleStubSession) Current() (vault.Model, error) { + return vault.Model{}, nil +} + +func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error { + s.createCalls++ + return nil +} + +func (s *lifecycleStubSession) Open(path string, _ vault.MasterKey) error { + s.openPath = path + return nil +} + +func (s *lifecycleStubSession) SaveAs(path string) error { + s.saveAsPath = path + return nil +} + +func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error { + s.remotePath = path + return nil +} diff --git a/clipboard/service.go b/clipboard/service.go new file mode 100644 index 0000000..730fe9a --- /dev/null +++ b/clipboard/service.go @@ -0,0 +1,81 @@ +package clipboard + +import ( + "errors" + "fmt" + + systemclipboard "github.com/atotto/clipboard" + + "git.julianfamily.org/keepassgo/vault" +) + +var ErrUnsupportedTarget = errors.New("unsupported clipboard target") + +type Target string + +const ( + TargetUsername Target = "username" + TargetPassword Target = "password" + TargetURL Target = "url" +) + +type Writer interface { + WriteText(text string) error +} + +type Service struct { + Writer Writer +} + +func (s Service) Copy(model vault.Model, entryID string, target Target) error { + entry, err := findEntry(model, entryID) + if err != nil { + return err + } + + content, err := contentForTarget(entry, target) + if err != nil { + return err + } + + if err := s.writer().WriteText(content); err != nil { + return fmt.Errorf("write clipboard text: %w", err) + } + + return nil +} + +func (s Service) writer() Writer { + if s.Writer != nil { + return s.Writer + } + return systemWriter{} +} + +func findEntry(model vault.Model, entryID string) (vault.Entry, error) { + for _, entry := range model.Entries { + if entry.ID == entryID { + return entry, nil + } + } + return vault.Entry{}, vault.ErrEntryNotFound +} + +func contentForTarget(entry vault.Entry, target Target) (string, error) { + switch target { + case TargetUsername: + return entry.Username, nil + case TargetPassword: + return entry.Password, nil + case TargetURL: + return entry.URL, nil + default: + return "", ErrUnsupportedTarget + } +} + +type systemWriter struct{} + +func (systemWriter) WriteText(text string) error { + return systemclipboard.WriteAll(text) +} diff --git a/clipboard/service_test.go b/clipboard/service_test.go new file mode 100644 index 0000000..9d769a0 --- /dev/null +++ b/clipboard/service_test.go @@ -0,0 +1,78 @@ +package clipboard + +import ( + "errors" + "testing" + + "git.julianfamily.org/keepassgo/vault" +) + +func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + }, + }, + } + + tests := []struct { + name string + target Target + want string + }{ + {name: "username", target: TargetUsername, want: "dannyocean"}, + {name: "password", target: TargetPassword, want: "token-1"}, + {name: "url", target: TargetURL, want: "https://vault.crew.example.invalid"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var writer memoryWriter + service := Service{Writer: &writer} + + if err := service.Copy(model, "vault-console", tt.target); err != nil { + t.Fatalf("Copy() error = %v", err) + } + + if writer.content != tt.want { + t.Fatalf("clipboard content = %q, want %q", writer.content, tt.want) + } + }) + } +} + +func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) { + t.Parallel() + + var writer memoryWriter + service := Service{Writer: &writer} + + err := service.Copy(vault.Model{}, "missing", TargetPassword) + if !errors.Is(err, vault.ErrEntryNotFound) { + t.Fatalf("Copy() missing entry error = %v, want ErrEntryNotFound", err) + } + + model := vault.Model{ + Entries: []vault.Entry{{ID: "vault-console", Username: "dannyocean"}}, + } + err = service.Copy(model, "vault-console", Target("unsupported")) + if !errors.Is(err, ErrUnsupportedTarget) { + t.Fatalf("Copy() unsupported target error = %v, want ErrUnsupportedTarget", err) + } +} + +type memoryWriter struct { + content string +} + +func (w *memoryWriter) WriteText(text string) error { + w.content = text + return nil +} diff --git a/docs/desktop-automation.md b/docs/desktop-automation.md new file mode 100644 index 0000000..2be5b5f --- /dev/null +++ b/docs/desktop-automation.md @@ -0,0 +1,34 @@ +# Desktop Automation Decision + +## Decision + +KeePassGO will not implement KeePass-style auto-type as its primary desktop automation mechanism. +The secure gRPC API is the replacement integration surface for trusted desktop automation, browser extensions, and local helper tools. + +## Why + +- The gRPC API gives a narrower and more explicit trust boundary than synthetic key injection. +- Browser and local automation clients can request exactly the credential operation they need: + - list entries + - list groups + - list templates + - create and mutate entries + - copy fields + - manage attachments + - generate passwords +- A typed authenticated API is easier to test and reason about than fake keystroke delivery to arbitrary windows. +- Synthetic typing is platform-specific, fragile, and substantially harder to make safe across Linux, Windows, and future Android support. +- The product requirement is integration capability comparable in purpose to KeePass auto-type, not literal keystroke emulation. + +## Product Consequences + +- Trusted desktop automation should be implemented as clients of the gRPC service. +- Browser integrations should target the gRPC API rather than ad hoc local protocols. +- UI workflows may still provide copy-to-clipboard behavior for direct human use. +- If a future use case demonstrates that gRPC plus clipboard is insufficient, native desktop automation can be reconsidered as a secondary capability, not as the baseline integration model. + +## Exit-Criteria Impact + +This document is the explicit resolution of the desktop automation requirement from [`AGENTS.md`](../AGENTS.md) and [`TODO.md`](../TODO.md): +- desktop automation is resolved by design +- the secure gRPC interface is the superseding trusted integration surface diff --git a/docs/kdbx-compatibility.md b/docs/kdbx-compatibility.md new file mode 100644 index 0000000..4ad3d16 --- /dev/null +++ b/docs/kdbx-compatibility.md @@ -0,0 +1,26 @@ +# KDBX Security Compatibility + +KeePassGO supports the following KDBX security workflows today: + +- open and save password-only vaults +- open and save key-file-only vaults +- open and save composite password-plus-key-file vaults +- preserve the original opened vault's KDBX format version during save +- preserve the original opened vault's cipher selection during save +- preserve the original opened vault's KDF selection during save + +What "preserve" means: + +- if a vault is opened through a managed session and then saved, KeePassGO reuses the opened vault's KDBX header configuration instead of replacing it with default header settings +- this applies to local and WebDAV-backed vault sessions + +Current explicit limitations: + +- KeePassGO does not yet provide a UI for editing cipher or KDF parameters directly +- new vault creation still uses the library default KDBX header settings for freshly created databases +- unsupported or unknown header fields outside the preserved header structures are not guaranteed to round-trip if they are not represented by the underlying library + +Practical expectation: + +- existing KeePass/KeePass2Android-compatible vaults keep their major format, cipher, and KDF family when edited and saved through KeePassGO +- KeePassGO does not yet try to be a full advanced database-settings editor diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f4eb6f7 --- /dev/null +++ b/go.mod @@ -0,0 +1,210 @@ +module git.julianfamily.org/keepassgo + +go 1.26 + +require ( + gioui.org v0.9.0 + github.com/atotto/clipboard v0.1.4 + github.com/tobischo/gokeepasslib/v3 v3.6.2 + golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 +) + +require ( + 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect + 4d63.com/gochecknoglobals v0.2.2 // indirect + gioui.org/shader v1.0.8 // indirect + github.com/4meepo/tagalign v1.4.2 // indirect + github.com/Abirdcfly/dupword v0.1.3 // indirect + github.com/Antonboom/errname v1.0.0 // indirect + github.com/Antonboom/nilnil v1.0.1 // indirect + github.com/Antonboom/testifylint v1.5.2 // indirect + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + github.com/Crocmagnon/fatcontext v0.7.1 // indirect + github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect + github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect + github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/alecthomas/go-check-sumtype v0.3.1 // indirect + github.com/alexkohler/nakedret/v2 v2.0.5 // indirect + github.com/alexkohler/prealloc v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/alingse/nilnesserr v0.1.2 // indirect + github.com/ashanbrown/forbidigo v1.6.0 // indirect + github.com/ashanbrown/makezero v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bkielbasa/cyclop v1.2.3 // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bombsimon/wsl/v4 v4.5.0 // indirect + github.com/breml/bidichk v0.3.2 // indirect + github.com/breml/errchkjson v0.4.0 // indirect + github.com/butuzov/ireturn v0.3.1 // indirect + github.com/butuzov/mirror v1.3.0 // indirect + github.com/catenacyber/perfsprint v0.8.2 // indirect + github.com/ccojocar/zxcvbn-go v1.0.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charithe/durationcheck v0.0.10 // indirect + github.com/chavacava/garif v0.1.0 // indirect + github.com/ckaznocha/intrange v0.3.0 // indirect + github.com/curioswitch/go-reassign v0.3.0 // indirect + github.com/daixiang0/gci v0.13.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/denis-tingaikin/go-header v0.5.0 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/firefart/nonamedreturns v1.0.5 // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/ghostiam/protogetter v0.3.9 // indirect + github.com/go-critic/go-critic v0.12.0 // indirect + github.com/go-text/typesetting v0.3.0 // indirect + github.com/go-toolsmith/astcast v1.1.0 // indirect + github.com/go-toolsmith/astcopy v1.1.0 // indirect + github.com/go-toolsmith/astequal v1.2.0 // indirect + github.com/go-toolsmith/astfmt v1.1.0 // indirect + github.com/go-toolsmith/astp v1.1.0 // indirect + github.com/go-toolsmith/strparse v1.1.0 // indirect + github.com/go-toolsmith/typep v1.1.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect + github.com/golangci/go-printf-func-name v0.1.0 // indirect + github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect + github.com/golangci/golangci-lint v1.64.8 // indirect + github.com/golangci/misspell v0.6.0 // indirect + github.com/golangci/plugin-module-register v0.1.1 // indirect + github.com/golangci/revgrep v0.8.0 // indirect + github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/gordonklaus/ineffassign v0.1.0 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.5.0 // indirect + github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jgautheron/goconst v1.7.1 // indirect + github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jjti/go-spancheck v0.6.4 // indirect + github.com/julz/importas v0.2.0 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect + github.com/kisielk/errcheck v1.9.0 // indirect + github.com/kkHAIKE/contextcheck v1.1.6 // indirect + github.com/kulti/thelper v0.6.3 // indirect + github.com/kunwardeep/paralleltest v1.0.10 // indirect + github.com/lasiar/canonicalheader v1.1.2 // indirect + github.com/ldez/exptostd v0.4.2 // indirect + github.com/ldez/gomoddirectives v0.6.1 // indirect + github.com/ldez/grignotin v0.9.0 // indirect + github.com/ldez/tagliatelle v0.7.1 // indirect + github.com/ldez/usetesting v0.4.2 // indirect + github.com/leonklingele/grouper v1.1.2 // indirect + github.com/macabu/inamedparam v0.1.3 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/maratori/testableexamples v1.0.0 // indirect + github.com/maratori/testpackage v1.1.1 // indirect + github.com/matoous/godox v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mgechev/revive v1.7.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moricho/tparallel v0.3.2 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect + github.com/nishanths/exhaustive v0.12.0 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/nunnatsa/ginkgolinter v0.19.1 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/polyfloyd/go-errorlint v1.7.1 // indirect + github.com/prometheus/client_golang v1.12.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect + github.com/quasilyte/gogrep v0.5.0 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/ryancurrah/gomodguard v1.3.5 // indirect + github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect + github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/sashamelentyev/interfacebloat v1.1.0 // indirect + github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect + github.com/securego/gosec/v2 v2.22.2 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sivchari/containedctx v1.0.3 // indirect + github.com/sivchari/tenv v1.12.1 // indirect + github.com/sonatard/noctx v0.1.0 // indirect + github.com/sourcegraph/go-diff v0.7.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.12.0 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.4.1 // indirect + github.com/tdakkota/asciicheck v0.4.1 // indirect + github.com/tetafro/godot v1.5.0 // indirect + github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect + github.com/timonwong/loggercheck v0.10.1 // indirect + github.com/tobischo/argon2 v0.1.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect + github.com/ultraware/funlen v0.2.0 // indirect + github.com/ultraware/whitespace v0.2.0 // indirect + github.com/uudashr/gocognit v1.2.0 // indirect + github.com/uudashr/iface v1.3.1 // indirect + github.com/xen0n/gosmopolitan v1.2.2 // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.3.0 // indirect + github.com/ykadowak/zerologlint v0.1.5 // indirect + gitlab.com/bosi/decorder v0.4.2 // indirect + go-simpler.org/musttag v0.13.0 // indirect + go-simpler.org/sloglint v0.9.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/image v0.37.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/tools v0.6.1 // indirect + mvdan.cc/gofumpt v0.7.0 // indirect + mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect +) + +tool github.com/golangci/golangci-lint/cmd/golangci-lint diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..80dcd53 --- /dev/null +++ b/go.sum @@ -0,0 +1,1024 @@ +4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A= +4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= +4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= +4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc= +gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= +github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= +github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= +github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= +github.com/Antonboom/errname v1.0.0 h1:oJOOWR07vS1kRusl6YRSlat7HFnb3mSfMl6sDMRoTBA= +github.com/Antonboom/errname v1.0.0/go.mod h1:gMOBFzK/vrTiXN9Oh+HFs+e6Ndl0eTFbtsRTSRdXyGI= +github.com/Antonboom/nilnil v1.0.1 h1:C3Tkm0KUxgfO4Duk3PM+ztPncTFlOf0b2qadmS0s4xs= +github.com/Antonboom/nilnil v1.0.1/go.mod h1:CH7pW2JsRNFgEh8B2UaPZTEPhCMuFowP/e8Udp9Nnb0= +github.com/Antonboom/testifylint v1.5.2 h1:4s3Xhuv5AvdIgbd8wOOEeo0uZG7PbDKQyKY5lGoQazk= +github.com/Antonboom/testifylint v1.5.2/go.mod h1:vxy8VJ0bc6NavlYqjZfmp6EfqXMtBgQ4+mhCojwC1P8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Crocmagnon/fatcontext v0.7.1 h1:SC/VIbRRZQeQWj/TcQBS6JmrXcfA+BU4OGSVUt54PjM= +github.com/Crocmagnon/fatcontext v0.7.1/go.mod h1:1wMvv3NXEBJucFGfwOJBxSVWcoIO6emV215SMkW9MFU= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= +github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= +github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= +github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= +github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= +github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexkohler/nakedret/v2 v2.0.5 h1:fP5qLgtwbx9EJE8dGEERT02YwS8En4r9nnZ71RK+EVU= +github.com/alexkohler/nakedret/v2 v2.0.5/go.mod h1:bF5i0zF2Wo2o4X4USt9ntUWve6JbFv02Ff4vlkmS/VU= +github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= +github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/alingse/nilnesserr v0.1.2 h1:Yf8Iwm3z2hUUrP4muWfW83DF4nE3r1xZ26fGWUKCZlo= +github.com/alingse/nilnesserr v0.1.2/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= +github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= +github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= +github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= +github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= +github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= +github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= +github.com/breml/bidichk v0.3.2/go.mod h1:VzFLBxuYtT23z5+iVkamXO386OB+/sVwZOpIj6zXGos= +github.com/breml/errchkjson v0.4.0 h1:gftf6uWZMtIa/Is3XJgibewBm2ksAQSY/kABDNFTAdk= +github.com/breml/errchkjson v0.4.0/go.mod h1:AuBOSTHyLSaaAFlWsRSuRBIroCh3eh7ZHh5YeelDIk8= +github.com/butuzov/ireturn v0.3.1 h1:mFgbEI6m+9W8oP/oDdfA34dLisRFCj2G6o/yiI1yZrY= +github.com/butuzov/ireturn v0.3.1/go.mod h1:ZfRp+E7eJLC0NQmk1Nrm1LOrn/gQlOykv+cVPdiXH5M= +github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= +github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= +github.com/catenacyber/perfsprint v0.8.2 h1:+o9zVmCSVa7M4MvabsWvESEhpsMkhfE7k0sHNGL95yw= +github.com/catenacyber/perfsprint v0.8.2/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= +github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= +github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= +github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= +github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= +github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/ckaznocha/intrange v0.3.0 h1:VqnxtK32pxgkhJgYQEeOArVidIPg+ahLP7WBOXZd5ZY= +github.com/ckaznocha/intrange v0.3.0/go.mod h1:+I/o2d2A1FBHgGELbGxzIcyd3/9l9DuwjM8FsbSS3Lo= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= +github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= +github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c= +github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= +github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA= +github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= +github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= +github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= +github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= +github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw= +github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk= +github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= +github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= +github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= +github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= +github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= +github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= +github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I= +github.com/golangci/golangci-lint v1.64.8/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4= +github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= +github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= +github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= +github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= +github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= +github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs= +github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= +github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= +github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= +github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= +github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= +github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= +github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= +github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= +github.com/jgautheron/goconst v1.7.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= +github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= +github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= +github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= +github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= +github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= +github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= +github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= +github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= +github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= +github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= +github.com/kunwardeep/paralleltest v1.0.10 h1:wrodoaKYzS2mdNVnc4/w31YaXFtsc21PCTdvWJ/lDDs= +github.com/kunwardeep/paralleltest v1.0.10/go.mod h1:2C7s65hONVqY7Q5Efj5aLzRCNLjw2h4eMc9EcypGjcY= +github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= +github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= +github.com/ldez/exptostd v0.4.2 h1:l5pOzHBz8mFOlbcifTxzfyYbgEmoUqjxLFHZkjlbHXs= +github.com/ldez/exptostd v0.4.2/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= +github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= +github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= +github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= +github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= +github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= +github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= +github.com/ldez/usetesting v0.4.2 h1:J2WwbrFGk3wx4cZwSMiCQQ00kjGR0+tuuyW0Lqm4lwA= +github.com/ldez/usetesting v0.4.2/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= +github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= +github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= +github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= +github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= +github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= +github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= +github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= +github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= +github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY= +github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= +github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= +github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= +github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L5TVMCnA= +github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo= +github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI= +github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= +github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= +github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.3.5 h1:cShyguSwUEeC0jS7ylOiG/idnd1TpJ1LfHGpV3oJmPU= +github.com/ryancurrah/gomodguard v1.3.5/go.mod h1:MXlEPQRxgfPQa62O8wzK3Ozbkv9Rkqr+wKjSxTdsNJE= +github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= +github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= +github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= +github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= +github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= +github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g= +github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= +github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw= +github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= +github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= +github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= +github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= +github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= +github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= +github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= +github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= +github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 h1:y4mJRFlM6fUyPhoXuFg/Yu02fg/nIPFMOY8tOqppoFg= +github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= +github.com/timonwong/loggercheck v0.10.1 h1:uVZYClxQFpw55eh+PIoqM7uAOHMrhVcDoWDery9R8Lg= +github.com/timonwong/loggercheck v0.10.1/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= +github.com/tobischo/argon2 v0.1.0 h1:mwAx/9DK/4rP0xzNifb/XMAf43dU3eG1B3aeF88qu4Y= +github.com/tobischo/argon2 v0.1.0/go.mod h1:4NLmLFwhWPbT66nRZNgcktV/mibJ6fESoeEp43h9GRw= +github.com/tobischo/gokeepasslib/v3 v3.6.2 h1:SJzzllmNe7iZLudLJ3Lzdm3pDb++AJqZlmqG+SR8bVc= +github.com/tobischo/gokeepasslib/v3 v3.6.2/go.mod h1:ga7HFqG0TZSLNao/QOnV2+yngkrf5186saPxSQ1Xp7o= +github.com/tomarrell/wrapcheck/v2 v2.10.0 h1:SzRCryzy4IrAH7bVGG4cK40tNUhmVmMDuJujy4XwYDg= +github.com/tomarrell/wrapcheck/v2 v2.10.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= +github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= +github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= +github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= +github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= +github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= +github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= +github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= +github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= +github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= +github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= +github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= +gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= +go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= +go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= +go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= +go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM= +go-simpler.org/sloglint v0.9.0 h1:/40NQtjRx9txvsB/RN022KsUJU+zaaSb/9q9BSefSrE= +go-simpler.org/sloglint v0.9.0/go.mod h1:G/OrAF6uxj48sHahCzrbarVMptL2kjWTaUeC8+fOGww= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= +golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= +golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= +golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= +honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= +mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= +mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= +mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f/go.mod h1:RSLa7mKKCNeTTMHBw5Hsy2rfJmd6O2ivt9Dw9ZqCQpQ= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..bac47a3 --- /dev/null +++ b/main.go @@ -0,0 +1,1122 @@ +package main + +import ( + "fmt" + "flag" + "image" + "image/color" + "os" + "strings" + + "git.julianfamily.org/keepassgo/appstate" + "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" + "gioui.org/app" + "gioui.org/gesture" + "gioui.org/io/pointer" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" + "golang.org/x/exp/shiny/materialdesign/icons" +) + +type entry = vault.Entry + +type ui struct { + mode string + theme *material.Theme + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + historyIndex widget.Editor + groupName widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + list widget.List + detailList widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + openURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + createGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + togglePasswordInline widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + entryClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + state appstate.State + visible []entry + currentPath []string + showPassword bool + togglePassword widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + statusMessage string + errorMessage string +} + +var ( + bgColor = color.NRGBA{R: 242, G: 239, B: 233, A: 255} + panelColor = color.NRGBA{R: 250, G: 248, B: 244, A: 255} + accentColor = color.NRGBA{R: 28, G: 83, B: 63, A: 255} + mutedColor = color.NRGBA{R: 95, G: 93, B: 88, A: 255} + selectedColor = color.NRGBA{R: 221, G: 233, B: 226, A: 255} + selectedEdge = color.NRGBA{R: 73, G: 123, B: 100, A: 255} +) + +func newUI(mode string) *ui { + return newUIWithSession(mode, &session.Manager{}) +} + +func newUIWithModel(mode string, model vault.Model) *ui { + return newUIWithState(mode, &uiSession{model: model}) +} + +func newUIWithSession(mode string, sess appstate.CurrentSession) *ui { + return newUIWithState(mode, sess) +} + +func newUIWithState(mode string, sess appstate.CurrentSession) *ui { + th := material.NewTheme() + th.Palette.Bg = bgColor + th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255} + th.Palette.ContrastBg = accentColor + th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + + u := &ui{ + mode: mode, + theme: th, + search: widget.Editor{ + SingleLine: true, + Submit: false, + }, + vaultPath: widget.Editor{SingleLine: true, Submit: false}, + saveAsPath: widget.Editor{SingleLine: true, Submit: false}, + remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, + remotePath: widget.Editor{SingleLine: true, Submit: false}, + remoteUsername: widget.Editor{SingleLine: true, Submit: false}, + remotePassword: widget.Editor{SingleLine: true, Submit: false}, + masterPassword: widget.Editor{SingleLine: true, Submit: false}, + keyFilePath: widget.Editor{SingleLine: true, Submit: false}, + entryID: widget.Editor{SingleLine: true, Submit: false}, + entryTitle: widget.Editor{SingleLine: true, Submit: false}, + entryUsername: widget.Editor{SingleLine: true, Submit: false}, + entryPassword: widget.Editor{SingleLine: true, Submit: false}, + entryURL: widget.Editor{SingleLine: true, Submit: false}, + entryNotes: widget.Editor{SingleLine: false, Submit: false}, + entryTags: widget.Editor{SingleLine: true, Submit: false}, + entryPath: widget.Editor{SingleLine: true, Submit: false}, + entryFields: widget.Editor{SingleLine: false, Submit: false}, + historyIndex: widget.Editor{SingleLine: true, Submit: false}, + groupName: widget.Editor{SingleLine: true, Submit: false}, + passwordProfile: widget.Editor{SingleLine: true, Submit: false}, + attachmentName: widget.Editor{SingleLine: true, Submit: false}, + attachmentPath: widget.Editor{SingleLine: true, Submit: false}, + exportAttachmentPath: widget.Editor{SingleLine: true, Submit: false}, + list: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, + detailList: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, + state: appstate.State{}, + } + u.state.Session = sess + u.phoneSplit.Value = 0.46 + u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) + u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) + u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) + u.passwordProfile.SetText("strong") + u.filter() + return u +} + +func (u *ui) filter() { + u.state.SearchQuery = u.search.Text() + u.state.CurrentPath = append([]string(nil), u.currentPath...) + visible, err := u.state.VisibleEntries() + if err != nil { + u.visible = nil + return + } + u.visible = visible + if len(u.entryClicks) < len(u.visible) { + u.entryClicks = make([]widget.Clickable, len(u.visible)) + } +} + +func (u *ui) showEntriesSection() { + u.state.Section = appstate.SectionEntries + u.currentPath = nil + u.filter() +} + +func (u *ui) showTemplatesSection() { + u.state.Section = appstate.SectionTemplates + u.currentPath = nil + u.filter() +} + +func (u *ui) showRecycleBinSection() { + u.state.Section = appstate.SectionRecycleBin + u.currentPath = nil + u.filter() +} + +func (u *ui) childGroups() []string { + u.state.SearchQuery = u.search.Text() + u.state.CurrentPath = append([]string(nil), u.currentPath...) + groups, err := u.state.ChildGroups() + if err != nil { + return nil + } + return groups +} + +func (u *ui) filteredTitles() []string { + titles := make([]string, 0, len(u.visible)) + for _, item := range u.visible { + titles = append(titles, item.Title) + } + return titles +} + +func (u *ui) selectedEntry() (entry, bool) { + for _, item := range u.visible { + if item.ID == u.state.SelectedEntryID { + return item, true + } + } + model, err := u.state.Session.Current() + if err != nil { + return entry{}, false + } + for _, item := range model.Entries { + if item.ID == u.state.SelectedEntryID { + return item, true + } + } + for _, item := range model.Templates { + if item.ID == u.state.SelectedEntryID { + return item, true + } + } + for _, item := range model.RecycleBin { + if item.ID == u.state.SelectedEntryID { + return item, true + } + } + return entry{}, false +} + +func (u *ui) currentMasterKey() (vault.MasterKey, error) { + key := vault.MasterKey{Password: u.masterPassword.Text()} + + path := strings.TrimSpace(u.keyFilePath.Text()) + if path == "" { + return key, nil + } + + content, err := os.ReadFile(path) + if err != nil { + return vault.MasterKey{}, fmt.Errorf("read key file: %w", err) + } + key.KeyFileData = content + return key, nil +} + +func (u *ui) createVaultAction() error { + key, err := u.currentMasterKey() + if err != nil { + return err + } + if err := u.state.CreateVault(key); err != nil { + return err + } + u.currentPath = nil + u.filter() + return nil +} + +func (u *ui) openVaultAction() error { + key, err := u.currentMasterKey() + if err != nil { + return err + } + if err := u.state.OpenVault(strings.TrimSpace(u.vaultPath.Text()), key); err != nil { + return err + } + u.currentPath = nil + u.filter() + return nil +} + +func (u *ui) saveAction() error { + if err := u.state.Save(); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) saveAsAction() error { + if err := u.state.SaveAs(strings.TrimSpace(u.saveAsPath.Text())); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) openRemoteAction() error { + key, err := u.currentMasterKey() + if err != nil { + return err + } + client := webdav.Client{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + } + if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { + return err + } + u.currentPath = nil + u.filter() + return nil +} + +func (u *ui) lockAction() error { + if err := u.state.Lock(); err != nil { + return err + } + u.showPassword = false + u.filter() + return nil +} + +func (u *ui) unlockAction() error { + key, err := u.currentMasterKey() + if err != nil { + return err + } + if err := u.state.Unlock(key); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) runAction(label string, action func() error) { + if err := action(); err != nil { + u.errorMessage = err.Error() + u.statusMessage = "" + return + } + u.errorMessage = "" + u.statusMessage = label + " complete" +} + +func (u *ui) ensureNavClickables() { + if len(u.breadcrumbs) < len(u.currentPath)+1 { + u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) + } +} + +func (u *ui) layout(gtx layout.Context) layout.Dimensions { + u.processShortcuts(gtx) + for u.createVault.Clicked(gtx) { + u.runAction("create vault", u.createVaultAction) + } + for u.openVault.Clicked(gtx) { + u.runAction("open vault", u.openVaultAction) + } + for u.saveVault.Clicked(gtx) { + u.runAction("save vault", u.saveAction) + } + for u.saveAsVault.Clicked(gtx) { + u.runAction("save-as vault", u.saveAsAction) + } + for u.openRemote.Clicked(gtx) { + u.runAction("open remote vault", u.openRemoteAction) + } + for u.unlockVault.Clicked(gtx) { + u.runAction("unlock vault", u.unlockAction) + } + for u.showEntries.Clicked(gtx) { + u.showEntriesSection() + } + for u.showTemplates.Clicked(gtx) { + u.showTemplatesSection() + } + for u.showRecycle.Clicked(gtx) { + u.showRecycleBinSection() + } + for u.lockVault.Clicked(gtx) { + u.runAction("lock vault", u.lockAction) + } + for u.addEntry.Clicked(gtx) { + u.state.SelectedEntryID = "" + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.statusMessage = "new entry form ready" + u.errorMessage = "" + } + for u.saveEntry.Clicked(gtx) { + u.runAction("save entry", u.saveEntryAction) + } + for u.duplicateEntry.Clicked(gtx) { + u.runAction("duplicate entry", u.duplicateSelectedEntryAction) + } + for u.deleteEntry.Clicked(gtx) { + u.runAction("delete entry", u.deleteSelectedEntryAction) + } + for u.restoreEntry.Clicked(gtx) { + u.runAction("restore entry", u.restoreSelectedRecycleEntryAction) + } + for u.saveTemplate.Clicked(gtx) { + u.runAction("save template", u.saveTemplateAction) + } + for u.deleteTemplate.Clicked(gtx) { + u.runAction("delete template", u.deleteSelectedTemplateAction) + } + for u.instantiateTemplate.Clicked(gtx) { + u.runAction("instantiate template", u.instantiateSelectedTemplateAction) + } + for u.addAttachment.Clicked(gtx) { + u.runAction("add attachment", u.addAttachmentAction) + } + for u.removeAttachment.Clicked(gtx) { + u.runAction("remove attachment", u.removeAttachmentAction) + } + for u.exportAttachment.Clicked(gtx) { + u.runAction("export attachment", u.exportAttachmentAction) + } + for u.copyUser.Clicked(gtx) { + u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) + } + for u.copyPass.Clicked(gtx) { + u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) }) + } + for u.copyURL.Clicked(gtx) { + u.runAction("copy URL", func() error { return u.copySelectedFieldAction(clipboard.TargetURL) }) + } + for u.generatePassword.Clicked(gtx) { + u.runAction("generate password", u.generatePasswordAction) + } + for u.restoreHistory.Clicked(gtx) { + u.runAction("restore history", u.restoreSelectedHistoryAction) + } + for u.createGroup.Clicked(gtx) { + u.runAction("create group", u.createGroupAction) + } + for u.renameGroup.Clicked(gtx) { + u.runAction("rename group", u.renameGroupAction) + } + for u.deleteGroup.Clicked(gtx) { + u.runAction("delete group", u.deleteCurrentGroupAction) + } + for u.togglePassword.Clicked(gtx) { + u.showPassword = !u.showPassword + } + for u.togglePasswordInline.Clicked(gtx) { + u.showPassword = !u.showPassword + } + if _, changed := u.search.Update(gtx); changed { + u.filter() + } + inset := layout.UniformInset(unit.Dp(16)) + return layout.Background{}.Layout(gtx, fill(bgColor), func(gtx layout.Context) layout.Dimensions { + return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.header), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { + u.phoneSpan = gtx.Constraints.Max.Y + listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value) + if listHeight < gtx.Dp(unit.Dp(180)) { + listHeight = gtx.Dp(unit.Dp(180)) + } + if listHeight > gtx.Constraints.Max.Y-gtx.Dp(unit.Dp(220)) { + listHeight = gtx.Constraints.Max.Y - gtx.Dp(unit.Dp(220)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.Y = listHeight + gtx.Constraints.Max.Y = listHeight + return u.listPanel(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.phoneSlider), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Flexed(1, u.detailPanel), + ) + } + return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, + layout.Flexed(0.38, u.listPanel), + layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), + layout.Flexed(0.62, u.detailPanel), + ) + }), + ) + }) + }) +} + +func (u *ui) header(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(20), "Vault") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + return btn.Layout(gtx) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.lifecycleControls), + ) + }) + } + return card(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(24), "Vault") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), "A single app concept for desktop and Android") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.lockVault, "Lock") + return btn.Layout(gtx) + }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(u.lifecycleControls), + ) + }) +} + +func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { + panel := card + spacing := unit.Dp(12) + if u.mode == "phone" { + panel = compactCard + spacing = unit.Dp(8) + } + u.ensureNavClickables() + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(u.sectionBar), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(u.pathBar), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(u.groupBar), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(u.groupControls), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + } + return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(u.theme, &u.search, "Search vault") + editor.Color = u.theme.Palette.Fg + editor.HintColor = mutedColor + return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + }) + }), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + label := "Add Entry" + if u.mode == "phone" { + label = "+ Add Entry" + } + btn := material.Button(u.theme, &u.addEntry, label) + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + if len(u.visible) == 0 { + lbl := material.Label(u.theme, unit.Sp(16), "No entries match.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { + item := u.visible[i] + click := &u.entryClicks[i] + return u.entryRow(gtx, click, i, item) + }) + }), + ) + }) +} + +func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showTemplates, "Templates") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + }), + ) +} + +func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions { + for click.Clicked(gtx) { + _ = u.state.ToggleVisibleIndex(idx) + u.loadSelectedEntryIntoEditor() + } + return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + inset := unit.Dp(12) + titleSize := unit.Sp(18) + metaSize := unit.Sp(14) + urlSize := unit.Sp(13) + if u.mode == "phone" { + inset = unit.Dp(10) + titleSize = unit.Sp(16) + metaSize = unit.Sp(13) + urlSize = unit.Sp(12) + } + row := func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, titleSize, item.Title) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, metaSize, item.Username) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, urlSize, item.URL) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(u.search.Text()) == "" { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + w := gtx.Constraints.Max.X + if w < 1 { + w = 1 + } + paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op()) + return layout.Dimensions{Size: image.Pt(w, 1)} + }), + ) + }) + } + if item.ID == u.state.SelectedEntryID { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Constraints.Max.Y + } + paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + return layout.Dimensions{Size: size} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return row(gtx) + }), + ) + } + return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { + return row(gtx) + }) + }) +} + +func (u *ui) phoneSlider(gtx layout.Context) layout.Dimensions { + if u.mode != "phone" { + return layout.Dimensions{} + } + for { + e, ok := u.splitDrag.Update(gtx.Metric, gtx.Source, gesture.Vertical) + if !ok { + break + } + switch e.Kind { + case pointer.Press: + u.splitBase = u.phoneSplit.Value + u.splitStartY = e.Position.Y + case pointer.Drag: + if u.phoneSpan > 0 { + next := u.splitBase + (e.Position.Y-u.splitStartY)/float32(u.phoneSpan) + if next < 0.28 { + next = 0.28 + } + if next > 0.72 { + next = 0.72 + } + u.phoneSplit.Value = next + } + } + } + gtx.Constraints.Min.Y = gtx.Dp(unit.Dp(18)) + gtx.Constraints.Max.Y = gtx.Dp(unit.Dp(18)) + return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() + u.splitDrag.Add(gtx.Ops) + pointer.CursorRowResize.Add(gtx.Ops) + handleW := gtx.Dp(unit.Dp(84)) + handleH := gtx.Dp(unit.Dp(4)) + x := (gtx.Constraints.Min.X - handleW) / 2 + y := (gtx.Constraints.Min.Y - handleH) / 2 + paint.FillShape(gtx.Ops, color.NRGBA{R: 214, G: 208, B: 197, A: 255}, clip.Rect{Min: image.Pt(0, y+1), Max: image.Pt(gtx.Constraints.Min.X, y+2)}.Op()) + paint.FillShape(gtx.Ops, accentColor, clip.RRect{ + Rect: image.Rectangle{Min: image.Pt(x, y), Max: image.Pt(x + handleW, y + handleH)}, + NE: 2, NW: 2, SE: 2, SW: 2, + }.Op(gtx.Ops)) + return layout.Dimensions{Size: gtx.Constraints.Min} + }) +} + +func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { + panel := card + if u.mode == "phone" { + panel = compactCard + } + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + item, ok := u.selectedEntry() + if !ok { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(16), "Edit the current form to create or update an item") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.entryEditorPanel), + ) + } + password := strings.Repeat("•", max(8, len(item.Password))) + if u.showPassword { + password = item.Password + } + titleSize := unit.Sp(26) + titlePad := unit.Dp(10) + sectionGap := unit.Dp(8) + if u.mode == "phone" { + titleSize = unit.Sp(18) + titlePad = unit.Dp(6) + sectionGap = unit.Dp(6) + } + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, titleSize, item.Title) + lbl.Color = accentColor + return lbl.Layout(gtx) + }, + layout.Spacer{Height: titlePad}.Layout, + detailLine(u.theme, "Path", strings.Join(item.Path, " / ")), + layout.Spacer{Height: sectionGap}.Layout, + detailLine(u.theme, "Username", item.Username), + layout.Spacer{Height: sectionGap}.Layout, + u.passwordLine("Password", password), + layout.Spacer{Height: sectionGap}.Layout, + detailLine(u.theme, "URL", item.URL), + layout.Spacer{Height: sectionGap}.Layout, + detailLine(u.theme, "Tags", strings.Join(item.Tags, ", ")), + layout.Spacer{Height: unit.Dp(12)}.Layout, + func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openURL, "Open URL") + }), + ) + } + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.copyUser, "Copy User") + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.copyPass, "Copy Password") + return btn.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.Button(u.theme, &u.openURL, "Open URL") + return btn.Layout(gtx) + }), + ) + }, + layout.Spacer{Height: unit.Dp(12)}.Layout, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Body1(u.theme, item.Notes) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }, + layout.Spacer{Height: unit.Dp(12)}.Layout, + u.entryEditorPanel, + } + return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }) +} + +func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionRecycleBin { + lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + + crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...) + if u.state.Section == appstate.SectionTemplates { + crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(crumbs)*2) + for i, name := range crumbs { + index := i + label := name + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.breadcrumbs[index].Clicked(gtx) { + if index == 0 { + u.currentPath = nil + } else { + u.currentPath = append([]string{}, crumbs[1:index+1]...) + } + u.filter() + } + btn := material.Button(u.theme, &u.breadcrumbs[index], label) + btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} + btn.Color = accentColor + btn.TextSize = unit.Sp(12) + btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10} + return btn.Layout(gtx) + })) + if i < len(crumbs)-1 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "/") + lbl.Color = mutedColor + return layout.UniformInset(unit.Dp(6)).Layout(gtx, lbl.Layout) + })) + } + } + return children + }()...) +} + +func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { + groups := append([]string{}, u.childGroups()...) + if len(u.groupClicks) < len(groups) { + u.groupClicks = make([]widget.Clickable, len(groups)) + } + if len(groups) == 0 { + return layout.Dimensions{} + } + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(groups)) + for i, group := range groups { + idx := i + name := group + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.groupClicks[idx].Clicked(gtx) { + u.currentPath = append(append([]string{}, u.currentPath...), name) + u.filter() + } + btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name) + btn.Background = color.NRGBA{R: 241, G: 236, B: 227, A: 255} + btn.Color = accentColor + btn.TextSize = unit.Sp(12) + return btn.Layout(gtx) + })) + } + return children + }()...) +} + +func detailLine(th *material.Theme, label, value string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(16), value) + return lbl.Layout(gtx) + }), + ) + } +} + +func (u *ui) passwordLine(label, value string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + icon := u.eyeIcon + desc := "Show password" + if u.showPassword { + icon = u.eyeOffIcon + desc = "Hide password" + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(label)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(16), value) + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(u.theme, &u.togglePasswordInline, icon, desc) + btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} + btn.Color = accentColor + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) + }), + ) + }), + ) + } +} + +func card(gtx layout.Context, w layout.Widget) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(16)).Layout(gtx, w) + }) +} + +func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { + return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, w) + }) +} + +func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { + border := color.NRGBA{R: 202, G: 194, B: 180, A: 255} + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Dp(unit.Dp(44)) + } + gtx.Constraints.Min = size + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, color.NRGBA{R: 255, G: 253, B: 249, A: 255}, clip.Rect{Max: size}.Op()) + return layout.Dimensions{Size: size} + }), + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) + paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) + paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return layout.Dimensions{Size: size} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + min := gtx.Constraints.Min + gtx.Constraints.Min = image.Point{} + dims := w(gtx) + if dims.Size.X < min.X { + dims.Size.X = min.X + } + if dims.Size.Y < min.Y { + dims.Size.Y = min.Y + } + if dims.Size.Y < gtx.Dp(unit.Dp(44)) { + dims.Size.Y = gtx.Dp(unit.Dp(44)) + } + return dims + }), + ) +} + +func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { + btn := material.Button(th, click, label) + btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + btn.Color = accentColor + btn.CornerRadius = unit.Dp(10) + btn.TextSize = unit.Sp(15) + return btn.Layout(gtx) +} + +func fill(c color.NRGBA) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, c, clip.Rect{Max: gtx.Constraints.Min}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Min} + } +} + +func main() { + mode := flag.String("mode", "desktop", "window mode: desktop or phone") + flag.Parse() + + width := unit.Dp(1180) + height := unit.Dp(760) + if strings.EqualFold(*mode, "phone") { + // Pixel 10 uses a 20:9 display; use a 412x915 dp viewport as a desktop-friendly preview. + width = unit.Dp(412) + height = unit.Dp(915) + } + + go func() { + w := new(app.Window) + w.Option( + app.Title("Vault Mock"), + app.Size(width, height), + ) + if err := run(w, strings.ToLower(*mode)); err != nil { + panic(err) + } + os.Exit(0) + }() + app.Main() +} + +func run(w *app.Window, mode string) error { + var ops op.Ops + ui := newUI(mode) + for { + e := w.Event() + switch e := e.(type) { + case app.DestroyEvent: + return e.Err + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + ui.layout(gtx) + e.Frame(gtx.Ops) + } + } +} + +type uiSession struct { + model vault.Model + locked bool +} + +func (s *uiSession) Current() (vault.Model, error) { + if s.locked { + return vault.Model{}, session.ErrLocked + } + return s.model, nil +} + +func (s *uiSession) Replace(model vault.Model) { + s.model = model +} + +func (s *uiSession) Lock() error { + s.locked = true + return nil +} + +func (s *uiSession) Unlock(vault.MasterKey) error { + if !s.locked { + return nil + } + s.locked = false + return nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8c2e3a8 --- /dev/null +++ b/main_test.go @@ -0,0 +1,530 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + + "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/session" + "git.julianfamily.org/keepassgo/vault" +) + +func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + }, + }) + + u.currentPath = []string{"Crew", "Internet"} + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio Vault Console]", got) + } + + u.search.SetText("surveillance") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Surveillance Console"}) { + t.Fatalf("search filteredTitles() = %v, want [Surveillance Console]", got) + } +} + +func TestUIChildGroupsComeFromVaultModel(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + {ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}}, + }, + }) + + u.currentPath = []string{"Crew"} + if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { + t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got) + } +} + +func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "2", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, + }, + }) + + u.currentPath = []string{"Crew", "Internet"} + u.filter() + u.state.SelectedEntryID = "2" + + got, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want true") + } + + if got.Title != "Vault Console" { + t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Vault Console") + } +} + +func TestUILockHidesVisibleEntries(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + }, + }) + + if err := u.state.Lock(); err != nil { + t.Fatalf("state.Lock() error = %v", err) + } + u.filter() + + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("filteredTitles() = %v, want empty while locked", got) + } +} + +func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + u.saveAsPath.SetText(path) + if err := u.saveAsAction(); err != nil { + t.Fatalf("saveAsAction() error = %v", err) + } + + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + u.currentPath = []string{"Root", "Internet"} + u.filter() + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("filteredTitles() = %v, want empty while locked", got) + } + + if err := u.unlockAction(); err != nil { + t.Fatalf("unlockAction() error = %v", err) + } + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() after unlock = %v, want [Vault Console]", got) + } + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.masterPassword.SetText("correct horse battery staple") + reopened.vaultPath.SetText(path) + if err := reopened.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + reopened.currentPath = []string{"Root", "Internet"} + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) + } +} + +func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var putCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vaults/main.kdbx") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + if err := u.saveAction(); err != nil { + t.Fatalf("saveAction() error = %v", err) + } + + if putCount != 1 { + t.Fatalf("remote PUT count = %d, want 1", putCount) + } +} + +func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) { + t.Parallel() + + keyFile := filepath.Join(t.TempDir(), "master.key") + keyData := []byte("key-file-bytes") + if err := os.WriteFile(keyFile, keyData, 0o600); err != nil { + t.Fatalf("WriteFile(keyFile) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.keyFilePath.SetText(keyFile) + + key, err := u.currentMasterKey() + if err != nil { + t.Fatalf("currentMasterKey() error = %v", err) + } + + if key.Password != "correct horse battery staple" { + t.Fatalf("MasterKey.Password = %q, want correct horse battery staple", key.Password) + } + if !bytes.Equal(key.KeyFileData, keyData) { + t.Fatalf("MasterKey.KeyFileData = %q, want %q", key.KeyFileData, keyData) + } +} + +func TestUISectionNavigationShowsTemplatesAndRecycleBin(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + }, + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}}, + }, + }) + + u.showTemplatesSection() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) { + t.Fatalf("template filteredTitles() = %v, want [Website Login]", got) + } + + u.showRecycleBinSection() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Entry"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted Entry]", got) + } + + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("entry filteredTitles() = %v, want [Vault Console]", got) + } +} + +func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.entryPassword.SetText("token-2") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() error = %v", err) + } + u.filter() + if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" { + t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry) + } + + if err := u.duplicateSelectedEntryAction(); err != nil { + t.Fatalf("duplicateSelectedEntryAction() error = %v", err) + } + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) { + t.Fatalf("filteredTitles() after duplicate = %v, want copy present", got) + } + + if err := u.deleteSelectedEntryAction(); err != nil { + t.Fatalf("deleteSelectedEntryAction() error = %v", err) + } + u.showRecycleBinSection() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console (Copy)"}) { + t.Fatalf("recycle filteredTitles() = %v, want deleted copy", got) + } + + u.state.SelectedEntryID = "vault-console-copy" + if err := u.restoreSelectedRecycleEntryAction(); err != nil { + t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err) + } + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) { + t.Fatalf("filteredTitles() after restore = %v, want restored copy", got) + } +} + +func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Templates: []vault.Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + Notes: "Reusable template", + Path: []string{"Templates", "Web"}, + }, + }, + }) + + u.showTemplatesSection() + u.filter() + u.state.SelectedEntryID = "tpl-1" + u.loadSelectedEntryIntoEditor() + u.entryTitle.SetText("Website Login Updated") + + if err := u.saveTemplateAction(); err != nil { + t.Fatalf("saveTemplateAction() error = %v", err) + } + + u.entryID.SetText("entry-1") + u.entryTitle.SetText("Bellagio") + u.entryUsername.SetText("rustyryan") + u.entryPassword.SetText("token-1") + u.entryURL.SetText("https://bellagio.example.invalid") + u.entryPath.SetText("Root / Internet") + if err := u.instantiateSelectedTemplateAction(); err != nil { + t.Fatalf("instantiateSelectedTemplateAction() error = %v", err) + } + + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "entry-1" + u.loadSelectedEntryIntoEditor() + + attachmentPath := filepath.Join(t.TempDir(), "token.txt") + attachmentExportPath := filepath.Join(t.TempDir(), "exported.txt") + content := []byte("attachment-content") + if err := os.WriteFile(attachmentPath, content, 0o600); err != nil { + t.Fatalf("WriteFile(attachmentPath) error = %v", err) + } + + u.attachmentPath.SetText(attachmentPath) + u.attachmentName.SetText("token.txt") + if err := u.addAttachmentAction(); err != nil { + t.Fatalf("addAttachmentAction() error = %v", err) + } + + u.exportAttachmentPath.SetText(attachmentExportPath) + if err := u.exportAttachmentAction(); err != nil { + t.Fatalf("exportAttachmentAction() error = %v", err) + } + + exported, err := os.ReadFile(attachmentExportPath) + if err != nil { + t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err) + } + if !bytes.Equal(exported, content) { + t.Fatalf("exported attachment = %q, want %q", exported, content) + } + + if err := u.removeAttachmentAction(); err != nil { + t.Fatalf("removeAttachmentAction() error = %v", err) + } + + u.showTemplatesSection() + u.filter() + u.state.SelectedEntryID = "tpl-1" + if err := u.deleteSelectedTemplateAction(); err != nil { + t.Fatalf("deleteSelectedTemplateAction() error = %v", err) + } + u.filter() + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("template filteredTitles() after delete = %v, want empty", got) + } +} + +func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + History: []vault.Entry{ + { + ID: "vault-console-h1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.historyIndex.SetText("0") + + if err := u.restoreSelectedHistoryAction(); err != nil { + t.Fatalf("restoreSelectedHistoryAction() error = %v", err) + } + u.filter() + if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" { + t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry) + } +} + +func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + + if err := u.performShortcut(shortcutNewEntry); err != nil { + t.Fatalf("performShortcut(new-entry) error = %v", err) + } + if u.state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty after new-entry shortcut", u.state.SelectedEntryID) + } + + u.state.SelectedEntryID = "vault-console" + if err := u.performShortcut(shortcutCopyUser); err != nil { + t.Fatalf("performShortcut(copy-user) error = %v", err) + } + if err := u.performShortcut(shortcutCopyPassword); err != nil { + t.Fatalf("performShortcut(copy-password) error = %v", err) + } + if err := u.performShortcut(shortcutCopyURL); err != nil { + t.Fatalf("performShortcut(copy-url) error = %v", err) + } +} + +func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.vaultPath.SetText("/does/not/exist.kdbx") + u.masterPassword.SetText("correct horse battery staple") + + u.runAction("open vault", u.openVaultAction) + if u.errorMessage == "" { + t.Fatal("errorMessage = empty, want visible action error") + } + + u = newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) + if u.statusMessage == "" { + t.Fatal("statusMessage = empty, want visible success status") + } +} diff --git a/passwords/generator.go b/passwords/generator.go new file mode 100644 index 0000000..ee535e2 --- /dev/null +++ b/passwords/generator.go @@ -0,0 +1,168 @@ +package passwords + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "strings" +) + +const ( + lowercaseChars = "abcdefghijkmnopqrstuvwxyz" + uppercaseChars = "ABCDEFGHJKLMNPQRSTUVWXYZ" + digitChars = "23456789" + symbolChars = "!@#$%^&*()-_=+[]{}<>?/." +) + +var ErrImpossibleProfile = errors.New("impossible password profile") + +type Profile struct { + Name string + Length int + Lowercase bool + Uppercase bool + Digits bool + Symbols bool + MinLowercase int + MinUppercase int + MinDigits int + MinSymbols int + ExcludeSimilar bool +} + +func DefaultProfiles() map[string]Profile { + return map[string]Profile{ + "strong": { + Name: "strong", + Length: 24, + Lowercase: true, + Uppercase: true, + Digits: true, + Symbols: true, + MinLowercase: 2, + MinUppercase: 2, + MinDigits: 2, + MinSymbols: 2, + ExcludeSimilar: true, + }, + "memorable": { + Name: "memorable", + Length: 20, + Lowercase: true, + Uppercase: true, + Digits: true, + Symbols: false, + MinLowercase: 4, + MinUppercase: 2, + MinDigits: 2, + ExcludeSimilar: true, + }, + } +} + +func Generate(profile Profile) (string, error) { + if err := validateProfile(profile); err != nil { + return "", err + } + + var chars []byte + var pool strings.Builder + if profile.Lowercase { + pool.WriteString(lowercaseChars) + chars = append(chars, mustRandomChars(lowercaseChars, profile.MinLowercase)...) + } + if profile.Uppercase { + pool.WriteString(uppercaseChars) + chars = append(chars, mustRandomChars(uppercaseChars, profile.MinUppercase)...) + } + if profile.Digits { + pool.WriteString(digitChars) + chars = append(chars, mustRandomChars(digitChars, profile.MinDigits)...) + } + if profile.Symbols { + pool.WriteString(symbolChars) + chars = append(chars, mustRandomChars(symbolChars, profile.MinSymbols)...) + } + + allChars := pool.String() + for len(chars) < profile.Length { + ch, err := randomChar(allChars) + if err != nil { + return "", err + } + chars = append(chars, ch) + } + + if err := shuffle(chars); err != nil { + return "", err + } + + return string(chars), nil +} + +func validateProfile(profile Profile) error { + if profile.Length <= 0 { + return fmt.Errorf("%w: length must be positive", ErrImpossibleProfile) + } + + required := profile.MinLowercase + profile.MinUppercase + profile.MinDigits + profile.MinSymbols + if required > profile.Length { + return fmt.Errorf("%w: minimum character counts exceed length", ErrImpossibleProfile) + } + + if profile.MinLowercase > 0 && !profile.Lowercase { + return fmt.Errorf("%w: lowercase disabled with lowercase minimum", ErrImpossibleProfile) + } + if profile.MinUppercase > 0 && !profile.Uppercase { + return fmt.Errorf("%w: uppercase disabled with uppercase minimum", ErrImpossibleProfile) + } + if profile.MinDigits > 0 && !profile.Digits { + return fmt.Errorf("%w: digits disabled with digit minimum", ErrImpossibleProfile) + } + if profile.MinSymbols > 0 && !profile.Symbols { + return fmt.Errorf("%w: symbols disabled with symbol minimum", ErrImpossibleProfile) + } + + if !profile.Lowercase && !profile.Uppercase && !profile.Digits && !profile.Symbols { + return fmt.Errorf("%w: no character sets enabled", ErrImpossibleProfile) + } + + return nil +} + +func mustRandomChars(chars string, count int) []byte { + if count <= 0 { + return nil + } + + out := make([]byte, 0, count) + for i := 0; i < count; i++ { + ch, err := randomChar(chars) + if err != nil { + panic(err) + } + out = append(out, ch) + } + return out +} + +func randomChar(chars string) (byte, error) { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + return 0, fmt.Errorf("random index: %w", err) + } + return chars[n.Int64()], nil +} + +func shuffle(chars []byte) error { + for i := len(chars) - 1; i > 0; i-- { + n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return fmt.Errorf("shuffle password: %w", err) + } + j := int(n.Int64()) + chars[i], chars[j] = chars[j], chars[i] + } + return nil +} diff --git a/passwords/generator_test.go b/passwords/generator_test.go new file mode 100644 index 0000000..9527718 --- /dev/null +++ b/passwords/generator_test.go @@ -0,0 +1,97 @@ +package passwords + +import ( + "strings" + "testing" +) + +func TestGenerateRespectsProfileRequirements(t *testing.T) { + t.Parallel() + + profile := Profile{ + Name: "strong", + Length: 24, + Lowercase: true, + Uppercase: true, + Digits: true, + Symbols: true, + MinLowercase: 2, + MinUppercase: 2, + MinDigits: 2, + MinSymbols: 2, + ExcludeSimilar: true, + } + + password, err := Generate(profile) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + if len(password) != 24 { + t.Fatalf("len(password) = %d, want 24", len(password)) + } + + if countFromSet(password, lowercaseChars) < 2 { + t.Fatalf("lowercase count in %q is too small", password) + } + + if countFromSet(password, uppercaseChars) < 2 { + t.Fatalf("uppercase count in %q is too small", password) + } + + if countFromSet(password, digitChars) < 2 { + t.Fatalf("digit count in %q is too small", password) + } + + if countFromSet(password, symbolChars) < 2 { + t.Fatalf("symbol count in %q is too small", password) + } + + if strings.ContainsAny(password, "O0Il1") { + t.Fatalf("password %q contains excluded similar characters", password) + } +} + +func TestGenerateRejectsImpossibleProfiles(t *testing.T) { + t.Parallel() + + _, err := Generate(Profile{ + Name: "bad", + Length: 6, + Lowercase: true, + Uppercase: true, + Digits: true, + Symbols: true, + MinLowercase: 2, + MinUppercase: 2, + MinDigits: 2, + MinSymbols: 2, + }) + if err == nil { + t.Fatal("Generate() error = nil, want impossible profile error") + } +} + +func TestProfileSetReturnsNamedProfiles(t *testing.T) { + t.Parallel() + + set := DefaultProfiles() + profile, ok := set["strong"] + if !ok { + t.Fatalf("DefaultProfiles()[\"strong\"] missing") + } + + if profile.Length < 20 || !profile.Symbols { + t.Fatalf("strong profile = %#v, want a strong reusable profile", profile) + } +} + +func countFromSet(password, chars string) int { + count := 0 + for _, r := range password { + if strings.ContainsRune(chars, r) { + count++ + } + } + return count +} diff --git a/proto/keepassgo/v1/keepassgo.pb.go b/proto/keepassgo/v1/keepassgo.pb.go new file mode 100644 index 0000000..7136e87 --- /dev/null +++ b/proto/keepassgo/v1/keepassgo.pb.go @@ -0,0 +1,2644 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.1 +// source: proto/keepassgo/v1/keepassgo.proto + +package keepassgov1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetSessionStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStatusRequest) Reset() { + *x = GetSessionStatusRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStatusRequest) ProtoMessage() {} + +func (x *GetSessionStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStatusRequest.ProtoReflect.Descriptor instead. +func (*GetSessionStatusRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{0} +} + +type GetSessionStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Locked bool `protobuf:"varint,1,opt,name=locked,proto3" json:"locked,omitempty"` + Dirty bool `protobuf:"varint,2,opt,name=dirty,proto3" json:"dirty,omitempty"` + EntryCount uint32 `protobuf:"varint,3,opt,name=entry_count,json=entryCount,proto3" json:"entry_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStatusResponse) Reset() { + *x = GetSessionStatusResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStatusResponse) ProtoMessage() {} + +func (x *GetSessionStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStatusResponse.ProtoReflect.Descriptor instead. +func (*GetSessionStatusResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{1} +} + +func (x *GetSessionStatusResponse) GetLocked() bool { + if x != nil { + return x.Locked + } + return false +} + +func (x *GetSessionStatusResponse) GetDirty() bool { + if x != nil { + return x.Dirty + } + return false +} + +func (x *GetSessionStatusResponse) GetEntryCount() uint32 { + if x != nil { + return x.EntryCount + } + return 0 +} + +type OpenVaultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + KeyFileData []byte `protobuf:"bytes,3,opt,name=key_file_data,json=keyFileData,proto3" json:"key_file_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVaultRequest) Reset() { + *x = OpenVaultRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVaultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVaultRequest) ProtoMessage() {} + +func (x *OpenVaultRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVaultRequest.ProtoReflect.Descriptor instead. +func (*OpenVaultRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{2} +} + +func (x *OpenVaultRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *OpenVaultRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *OpenVaultRequest) GetKeyFileData() []byte { + if x != nil { + return x.KeyFileData + } + return nil +} + +type OpenVaultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVaultResponse) Reset() { + *x = OpenVaultResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVaultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVaultResponse) ProtoMessage() {} + +func (x *OpenVaultResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVaultResponse.ProtoReflect.Descriptor instead. +func (*OpenVaultResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{3} +} + +type OpenRemoteVaultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + BaseUrl string `protobuf:"bytes,1,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + MasterPassword string `protobuf:"bytes,5,opt,name=master_password,json=masterPassword,proto3" json:"master_password,omitempty"` + KeyFileData []byte `protobuf:"bytes,6,opt,name=key_file_data,json=keyFileData,proto3" json:"key_file_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenRemoteVaultRequest) Reset() { + *x = OpenRemoteVaultRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenRemoteVaultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenRemoteVaultRequest) ProtoMessage() {} + +func (x *OpenRemoteVaultRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenRemoteVaultRequest.ProtoReflect.Descriptor instead. +func (*OpenRemoteVaultRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{4} +} + +func (x *OpenRemoteVaultRequest) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *OpenRemoteVaultRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *OpenRemoteVaultRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *OpenRemoteVaultRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *OpenRemoteVaultRequest) GetMasterPassword() string { + if x != nil { + return x.MasterPassword + } + return "" +} + +func (x *OpenRemoteVaultRequest) GetKeyFileData() []byte { + if x != nil { + return x.KeyFileData + } + return nil +} + +type OpenRemoteVaultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenRemoteVaultResponse) Reset() { + *x = OpenRemoteVaultResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenRemoteVaultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenRemoteVaultResponse) ProtoMessage() {} + +func (x *OpenRemoteVaultResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenRemoteVaultResponse.ProtoReflect.Descriptor instead. +func (*OpenRemoteVaultResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{5} +} + +type SaveVaultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveVaultRequest) Reset() { + *x = SaveVaultRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveVaultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveVaultRequest) ProtoMessage() {} + +func (x *SaveVaultRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SaveVaultRequest.ProtoReflect.Descriptor instead. +func (*SaveVaultRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{6} +} + +type SaveVaultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SaveVaultResponse) Reset() { + *x = SaveVaultResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SaveVaultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SaveVaultResponse) ProtoMessage() {} + +func (x *SaveVaultResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SaveVaultResponse.ProtoReflect.Descriptor instead. +func (*SaveVaultResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{7} +} + +type LockVaultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockVaultRequest) Reset() { + *x = LockVaultRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockVaultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockVaultRequest) ProtoMessage() {} + +func (x *LockVaultRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockVaultRequest.ProtoReflect.Descriptor instead. +func (*LockVaultRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{8} +} + +type LockVaultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockVaultResponse) Reset() { + *x = LockVaultResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockVaultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockVaultResponse) ProtoMessage() {} + +func (x *LockVaultResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockVaultResponse.ProtoReflect.Descriptor instead. +func (*LockVaultResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{9} +} + +type UnlockVaultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockVaultRequest) Reset() { + *x = UnlockVaultRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockVaultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockVaultRequest) ProtoMessage() {} + +func (x *UnlockVaultRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockVaultRequest.ProtoReflect.Descriptor instead. +func (*UnlockVaultRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{10} +} + +type UnlockVaultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockVaultResponse) Reset() { + *x = UnlockVaultResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockVaultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockVaultResponse) ProtoMessage() {} + +func (x *UnlockVaultResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockVaultResponse.ProtoReflect.Descriptor instead. +func (*UnlockVaultResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{11} +} + +type ListEntriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path []string `protobuf:"bytes,1,rep,name=path,proto3" json:"path,omitempty"` + Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesRequest) Reset() { + *x = ListEntriesRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesRequest) ProtoMessage() {} + +func (x *ListEntriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesRequest.ProtoReflect.Descriptor instead. +func (*ListEntriesRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{12} +} + +func (x *ListEntriesRequest) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +func (x *ListEntriesRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +type Entry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` + Notes string `protobuf:"bytes,6,opt,name=notes,proto3" json:"notes,omitempty"` + Tags []string `protobuf:"bytes,7,rep,name=tags,proto3" json:"tags,omitempty"` + Path []string `protobuf:"bytes,8,rep,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Entry) Reset() { + *x = Entry{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Entry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Entry) ProtoMessage() {} + +func (x *Entry) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Entry.ProtoReflect.Descriptor instead. +func (*Entry) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{13} +} + +func (x *Entry) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Entry) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Entry) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *Entry) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *Entry) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *Entry) GetNotes() string { + if x != nil { + return x.Notes + } + return "" +} + +func (x *Entry) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *Entry) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +type ListEntriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*Entry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesResponse) Reset() { + *x = ListEntriesResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesResponse) ProtoMessage() {} + +func (x *ListEntriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesResponse.ProtoReflect.Descriptor instead. +func (*ListEntriesResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{14} +} + +func (x *ListEntriesResponse) GetEntries() []*Entry { + if x != nil { + return x.Entries + } + return nil +} + +type ListGroupsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path []string `protobuf:"bytes,1,rep,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListGroupsRequest) Reset() { + *x = ListGroupsRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListGroupsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListGroupsRequest) ProtoMessage() {} + +func (x *ListGroupsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListGroupsRequest.ProtoReflect.Descriptor instead. +func (*ListGroupsRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{15} +} + +func (x *ListGroupsRequest) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +type ListGroupsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListGroupsResponse) Reset() { + *x = ListGroupsResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListGroupsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListGroupsResponse) ProtoMessage() {} + +func (x *ListGroupsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListGroupsResponse.ProtoReflect.Descriptor instead. +func (*ListGroupsResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{16} +} + +func (x *ListGroupsResponse) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +type CreateGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ParentPath []string `protobuf:"bytes,1,rep,name=parent_path,json=parentPath,proto3" json:"parent_path,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateGroupRequest) Reset() { + *x = CreateGroupRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateGroupRequest) ProtoMessage() {} + +func (x *CreateGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateGroupRequest.ProtoReflect.Descriptor instead. +func (*CreateGroupRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{17} +} + +func (x *CreateGroupRequest) GetParentPath() []string { + if x != nil { + return x.ParentPath + } + return nil +} + +func (x *CreateGroupRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type CreateGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateGroupResponse) Reset() { + *x = CreateGroupResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateGroupResponse) ProtoMessage() {} + +func (x *CreateGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateGroupResponse.ProtoReflect.Descriptor instead. +func (*CreateGroupResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{18} +} + +type RenameGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path []string `protobuf:"bytes,1,rep,name=path,proto3" json:"path,omitempty"` + NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameGroupRequest) Reset() { + *x = RenameGroupRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameGroupRequest) ProtoMessage() {} + +func (x *RenameGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameGroupRequest.ProtoReflect.Descriptor instead. +func (*RenameGroupRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{19} +} + +func (x *RenameGroupRequest) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +func (x *RenameGroupRequest) GetNewName() string { + if x != nil { + return x.NewName + } + return "" +} + +type RenameGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameGroupResponse) Reset() { + *x = RenameGroupResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameGroupResponse) ProtoMessage() {} + +func (x *RenameGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameGroupResponse.ProtoReflect.Descriptor instead. +func (*RenameGroupResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{20} +} + +type UpsertEntryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpsertEntryRequest) Reset() { + *x = UpsertEntryRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpsertEntryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpsertEntryRequest) ProtoMessage() {} + +func (x *UpsertEntryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpsertEntryRequest.ProtoReflect.Descriptor instead. +func (*UpsertEntryRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{21} +} + +func (x *UpsertEntryRequest) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +type UpsertEntryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpsertEntryResponse) Reset() { + *x = UpsertEntryResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpsertEntryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpsertEntryResponse) ProtoMessage() {} + +func (x *UpsertEntryResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpsertEntryResponse.ProtoReflect.Descriptor instead. +func (*UpsertEntryResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{22} +} + +func (x *UpsertEntryResponse) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +type DeleteEntryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteEntryRequest) Reset() { + *x = DeleteEntryRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteEntryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteEntryRequest) ProtoMessage() {} + +func (x *DeleteEntryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteEntryRequest.ProtoReflect.Descriptor instead. +func (*DeleteEntryRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{23} +} + +func (x *DeleteEntryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeleteEntryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteEntryResponse) Reset() { + *x = DeleteEntryResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteEntryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteEntryResponse) ProtoMessage() {} + +func (x *DeleteEntryResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteEntryResponse.ProtoReflect.Descriptor instead. +func (*DeleteEntryResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{24} +} + +type RestoreEntryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreEntryRequest) Reset() { + *x = RestoreEntryRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreEntryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreEntryRequest) ProtoMessage() {} + +func (x *RestoreEntryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreEntryRequest.ProtoReflect.Descriptor instead. +func (*RestoreEntryRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{25} +} + +func (x *RestoreEntryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RestoreEntryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreEntryResponse) Reset() { + *x = RestoreEntryResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreEntryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreEntryResponse) ProtoMessage() {} + +func (x *RestoreEntryResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreEntryResponse.ProtoReflect.Descriptor instead. +func (*RestoreEntryResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{26} +} + +func (x *RestoreEntryResponse) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +type ListEntryHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntryHistoryRequest) Reset() { + *x = ListEntryHistoryRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntryHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntryHistoryRequest) ProtoMessage() {} + +func (x *ListEntryHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntryHistoryRequest.ProtoReflect.Descriptor instead. +func (*ListEntryHistoryRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{27} +} + +func (x *ListEntryHistoryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type ListEntryHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*Entry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntryHistoryResponse) Reset() { + *x = ListEntryHistoryResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntryHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntryHistoryResponse) ProtoMessage() {} + +func (x *ListEntryHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntryHistoryResponse.ProtoReflect.Descriptor instead. +func (*ListEntryHistoryResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{28} +} + +func (x *ListEntryHistoryResponse) GetEntries() []*Entry { + if x != nil { + return x.Entries + } + return nil +} + +type RestoreEntryHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + HistoryIndex uint32 `protobuf:"varint,2,opt,name=history_index,json=historyIndex,proto3" json:"history_index,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreEntryHistoryRequest) Reset() { + *x = RestoreEntryHistoryRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreEntryHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreEntryHistoryRequest) ProtoMessage() {} + +func (x *RestoreEntryHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreEntryHistoryRequest.ProtoReflect.Descriptor instead. +func (*RestoreEntryHistoryRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{29} +} + +func (x *RestoreEntryHistoryRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *RestoreEntryHistoryRequest) GetHistoryIndex() uint32 { + if x != nil { + return x.HistoryIndex + } + return 0 +} + +type RestoreEntryHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreEntryHistoryResponse) Reset() { + *x = RestoreEntryHistoryResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreEntryHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreEntryHistoryResponse) ProtoMessage() {} + +func (x *RestoreEntryHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreEntryHistoryResponse.ProtoReflect.Descriptor instead. +func (*RestoreEntryHistoryResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{30} +} + +func (x *RestoreEntryHistoryResponse) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +type ListTemplatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTemplatesRequest) Reset() { + *x = ListTemplatesRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTemplatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTemplatesRequest) ProtoMessage() {} + +func (x *ListTemplatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTemplatesRequest.ProtoReflect.Descriptor instead. +func (*ListTemplatesRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{31} +} + +type ListTemplatesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Templates []*Entry `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTemplatesResponse) Reset() { + *x = ListTemplatesResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTemplatesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTemplatesResponse) ProtoMessage() {} + +func (x *ListTemplatesResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTemplatesResponse.ProtoReflect.Descriptor instead. +func (*ListTemplatesResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{32} +} + +func (x *ListTemplatesResponse) GetTemplates() []*Entry { + if x != nil { + return x.Templates + } + return nil +} + +type UpsertTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *Entry `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpsertTemplateRequest) Reset() { + *x = UpsertTemplateRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpsertTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpsertTemplateRequest) ProtoMessage() {} + +func (x *UpsertTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpsertTemplateRequest.ProtoReflect.Descriptor instead. +func (*UpsertTemplateRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{33} +} + +func (x *UpsertTemplateRequest) GetTemplate() *Entry { + if x != nil { + return x.Template + } + return nil +} + +type UpsertTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template *Entry `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpsertTemplateResponse) Reset() { + *x = UpsertTemplateResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpsertTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpsertTemplateResponse) ProtoMessage() {} + +func (x *UpsertTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpsertTemplateResponse.ProtoReflect.Descriptor instead. +func (*UpsertTemplateResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{34} +} + +func (x *UpsertTemplateResponse) GetTemplate() *Entry { + if x != nil { + return x.Template + } + return nil +} + +type DeleteTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTemplateRequest) Reset() { + *x = DeleteTemplateRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTemplateRequest) ProtoMessage() {} + +func (x *DeleteTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteTemplateRequest.ProtoReflect.Descriptor instead. +func (*DeleteTemplateRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{35} +} + +func (x *DeleteTemplateRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeleteTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTemplateResponse) Reset() { + *x = DeleteTemplateResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTemplateResponse) ProtoMessage() {} + +func (x *DeleteTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteTemplateResponse.ProtoReflect.Descriptor instead. +func (*DeleteTemplateResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{36} +} + +type InstantiateTemplateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TemplateId string `protobuf:"bytes,1,opt,name=template_id,json=templateId,proto3" json:"template_id,omitempty"` + Overrides *Entry `protobuf:"bytes,2,opt,name=overrides,proto3" json:"overrides,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstantiateTemplateRequest) Reset() { + *x = InstantiateTemplateRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstantiateTemplateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstantiateTemplateRequest) ProtoMessage() {} + +func (x *InstantiateTemplateRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstantiateTemplateRequest.ProtoReflect.Descriptor instead. +func (*InstantiateTemplateRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{37} +} + +func (x *InstantiateTemplateRequest) GetTemplateId() string { + if x != nil { + return x.TemplateId + } + return "" +} + +func (x *InstantiateTemplateRequest) GetOverrides() *Entry { + if x != nil { + return x.Overrides + } + return nil +} + +type InstantiateTemplateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstantiateTemplateResponse) Reset() { + *x = InstantiateTemplateResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstantiateTemplateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstantiateTemplateResponse) ProtoMessage() {} + +func (x *InstantiateTemplateResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstantiateTemplateResponse.ProtoReflect.Descriptor instead. +func (*InstantiateTemplateResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{38} +} + +func (x *InstantiateTemplateResponse) GetEntry() *Entry { + if x != nil { + return x.Entry + } + return nil +} + +type ListAttachmentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntryId string `protobuf:"bytes,1,opt,name=entry_id,json=entryId,proto3" json:"entry_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAttachmentsRequest) Reset() { + *x = ListAttachmentsRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAttachmentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAttachmentsRequest) ProtoMessage() {} + +func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead. +func (*ListAttachmentsRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{39} +} + +func (x *ListAttachmentsRequest) GetEntryId() string { + if x != nil { + return x.EntryId + } + return "" +} + +type ListAttachmentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAttachmentsResponse) Reset() { + *x = ListAttachmentsResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAttachmentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAttachmentsResponse) ProtoMessage() {} + +func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead. +func (*ListAttachmentsResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{40} +} + +func (x *ListAttachmentsResponse) GetNames() []string { + if x != nil { + return x.Names + } + return nil +} + +type UploadAttachmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntryId string `protobuf:"bytes,1,opt,name=entry_id,json=entryId,proto3" json:"entry_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Content []byte `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadAttachmentRequest) Reset() { + *x = UploadAttachmentRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadAttachmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadAttachmentRequest) ProtoMessage() {} + +func (x *UploadAttachmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadAttachmentRequest.ProtoReflect.Descriptor instead. +func (*UploadAttachmentRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{41} +} + +func (x *UploadAttachmentRequest) GetEntryId() string { + if x != nil { + return x.EntryId + } + return "" +} + +func (x *UploadAttachmentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UploadAttachmentRequest) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +type UploadAttachmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadAttachmentResponse) Reset() { + *x = UploadAttachmentResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadAttachmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadAttachmentResponse) ProtoMessage() {} + +func (x *UploadAttachmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadAttachmentResponse.ProtoReflect.Descriptor instead. +func (*UploadAttachmentResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{42} +} + +type DownloadAttachmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntryId string `protobuf:"bytes,1,opt,name=entry_id,json=entryId,proto3" json:"entry_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DownloadAttachmentRequest) Reset() { + *x = DownloadAttachmentRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DownloadAttachmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DownloadAttachmentRequest) ProtoMessage() {} + +func (x *DownloadAttachmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DownloadAttachmentRequest.ProtoReflect.Descriptor instead. +func (*DownloadAttachmentRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{43} +} + +func (x *DownloadAttachmentRequest) GetEntryId() string { + if x != nil { + return x.EntryId + } + return "" +} + +func (x *DownloadAttachmentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DownloadAttachmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DownloadAttachmentResponse) Reset() { + *x = DownloadAttachmentResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DownloadAttachmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DownloadAttachmentResponse) ProtoMessage() {} + +func (x *DownloadAttachmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DownloadAttachmentResponse.ProtoReflect.Descriptor instead. +func (*DownloadAttachmentResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{44} +} + +func (x *DownloadAttachmentResponse) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +type DeleteAttachmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntryId string `protobuf:"bytes,1,opt,name=entry_id,json=entryId,proto3" json:"entry_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAttachmentRequest) Reset() { + *x = DeleteAttachmentRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAttachmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAttachmentRequest) ProtoMessage() {} + +func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead. +func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{45} +} + +func (x *DeleteAttachmentRequest) GetEntryId() string { + if x != nil { + return x.EntryId + } + return "" +} + +func (x *DeleteAttachmentRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DeleteAttachmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteAttachmentResponse) Reset() { + *x = DeleteAttachmentResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteAttachmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteAttachmentResponse) ProtoMessage() {} + +func (x *DeleteAttachmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteAttachmentResponse.ProtoReflect.Descriptor instead. +func (*DeleteAttachmentResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{46} +} + +type CopyEntryFieldRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopyEntryFieldRequest) Reset() { + *x = CopyEntryFieldRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopyEntryFieldRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopyEntryFieldRequest) ProtoMessage() {} + +func (x *CopyEntryFieldRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopyEntryFieldRequest.ProtoReflect.Descriptor instead. +func (*CopyEntryFieldRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{47} +} + +func (x *CopyEntryFieldRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *CopyEntryFieldRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +type CopyEntryFieldResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CopyEntryFieldResponse) Reset() { + *x = CopyEntryFieldResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CopyEntryFieldResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CopyEntryFieldResponse) ProtoMessage() {} + +func (x *CopyEntryFieldResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CopyEntryFieldResponse.ProtoReflect.Descriptor instead. +func (*CopyEntryFieldResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{48} +} + +type GeneratePasswordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeneratePasswordRequest) Reset() { + *x = GeneratePasswordRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeneratePasswordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeneratePasswordRequest) ProtoMessage() {} + +func (x *GeneratePasswordRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeneratePasswordRequest.ProtoReflect.Descriptor instead. +func (*GeneratePasswordRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{49} +} + +func (x *GeneratePasswordRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +type GeneratePasswordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GeneratePasswordResponse) Reset() { + *x = GeneratePasswordResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GeneratePasswordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GeneratePasswordResponse) ProtoMessage() {} + +func (x *GeneratePasswordResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GeneratePasswordResponse.ProtoReflect.Descriptor instead. +func (*GeneratePasswordResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{50} +} + +func (x *GeneratePasswordResponse) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +var File_proto_keepassgo_v1_keepassgo_proto protoreflect.FileDescriptor + +const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + + "\n" + + "\"proto/keepassgo/v1/keepassgo.proto\x12\fkeepassgo.v1\"\x19\n" + + "\x17GetSessionStatusRequest\"i\n" + + "\x18GetSessionStatusResponse\x12\x16\n" + + "\x06locked\x18\x01 \x01(\bR\x06locked\x12\x14\n" + + "\x05dirty\x18\x02 \x01(\bR\x05dirty\x12\x1f\n" + + "\ventry_count\x18\x03 \x01(\rR\n" + + "entryCount\"f\n" + + "\x10OpenVaultRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1a\n" + + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\"\n" + + "\rkey_file_data\x18\x03 \x01(\fR\vkeyFileData\"\x13\n" + + "\x11OpenVaultResponse\"\xcc\x01\n" + + "\x16OpenRemoteVaultRequest\x12\x19\n" + + "\bbase_url\x18\x01 \x01(\tR\abaseUrl\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12'\n" + + "\x0fmaster_password\x18\x05 \x01(\tR\x0emasterPassword\x12\"\n" + + "\rkey_file_data\x18\x06 \x01(\fR\vkeyFileData\"\x19\n" + + "\x17OpenRemoteVaultResponse\"\x12\n" + + "\x10SaveVaultRequest\"\x13\n" + + "\x11SaveVaultResponse\"\x12\n" + + "\x10LockVaultRequest\"\x13\n" + + "\x11LockVaultResponse\"\x14\n" + + "\x12UnlockVaultRequest\"\x15\n" + + "\x13UnlockVaultResponse\">\n" + + "\x12ListEntriesRequest\x12\x12\n" + + "\x04path\x18\x01 \x03(\tR\x04path\x12\x14\n" + + "\x05query\x18\x02 \x01(\tR\x05query\"\xb5\x01\n" + + "\x05Entry\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05title\x18\x02 \x01(\tR\x05title\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x10\n" + + "\x03url\x18\x05 \x01(\tR\x03url\x12\x14\n" + + "\x05notes\x18\x06 \x01(\tR\x05notes\x12\x12\n" + + "\x04tags\x18\a \x03(\tR\x04tags\x12\x12\n" + + "\x04path\x18\b \x03(\tR\x04path\"D\n" + + "\x13ListEntriesResponse\x12-\n" + + "\aentries\x18\x01 \x03(\v2\x13.keepassgo.v1.EntryR\aentries\"'\n" + + "\x11ListGroupsRequest\x12\x12\n" + + "\x04path\x18\x01 \x03(\tR\x04path\"*\n" + + "\x12ListGroupsResponse\x12\x14\n" + + "\x05names\x18\x01 \x03(\tR\x05names\"I\n" + + "\x12CreateGroupRequest\x12\x1f\n" + + "\vparent_path\x18\x01 \x03(\tR\n" + + "parentPath\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\x15\n" + + "\x13CreateGroupResponse\"C\n" + + "\x12RenameGroupRequest\x12\x12\n" + + "\x04path\x18\x01 \x03(\tR\x04path\x12\x19\n" + + "\bnew_name\x18\x02 \x01(\tR\anewName\"\x15\n" + + "\x13RenameGroupResponse\"?\n" + + "\x12UpsertEntryRequest\x12)\n" + + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\"@\n" + + "\x13UpsertEntryResponse\x12)\n" + + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\"$\n" + + "\x12DeleteEntryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x15\n" + + "\x13DeleteEntryResponse\"%\n" + + "\x13RestoreEntryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"A\n" + + "\x14RestoreEntryResponse\x12)\n" + + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\")\n" + + "\x17ListEntryHistoryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"I\n" + + "\x18ListEntryHistoryResponse\x12-\n" + + "\aentries\x18\x01 \x03(\v2\x13.keepassgo.v1.EntryR\aentries\"Q\n" + + "\x1aRestoreEntryHistoryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + + "\rhistory_index\x18\x02 \x01(\rR\fhistoryIndex\"H\n" + + "\x1bRestoreEntryHistoryResponse\x12)\n" + + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\"\x16\n" + + "\x14ListTemplatesRequest\"J\n" + + "\x15ListTemplatesResponse\x121\n" + + "\ttemplates\x18\x01 \x03(\v2\x13.keepassgo.v1.EntryR\ttemplates\"H\n" + + "\x15UpsertTemplateRequest\x12/\n" + + "\btemplate\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\btemplate\"I\n" + + "\x16UpsertTemplateResponse\x12/\n" + + "\btemplate\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\btemplate\"'\n" + + "\x15DeleteTemplateRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x18\n" + + "\x16DeleteTemplateResponse\"p\n" + + "\x1aInstantiateTemplateRequest\x12\x1f\n" + + "\vtemplate_id\x18\x01 \x01(\tR\n" + + "templateId\x121\n" + + "\toverrides\x18\x02 \x01(\v2\x13.keepassgo.v1.EntryR\toverrides\"H\n" + + "\x1bInstantiateTemplateResponse\x12)\n" + + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\"3\n" + + "\x16ListAttachmentsRequest\x12\x19\n" + + "\bentry_id\x18\x01 \x01(\tR\aentryId\"/\n" + + "\x17ListAttachmentsResponse\x12\x14\n" + + "\x05names\x18\x01 \x03(\tR\x05names\"b\n" + + "\x17UploadAttachmentRequest\x12\x19\n" + + "\bentry_id\x18\x01 \x01(\tR\aentryId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + + "\acontent\x18\x03 \x01(\fR\acontent\"\x1a\n" + + "\x18UploadAttachmentResponse\"J\n" + + "\x19DownloadAttachmentRequest\x12\x19\n" + + "\bentry_id\x18\x01 \x01(\tR\aentryId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"6\n" + + "\x1aDownloadAttachmentResponse\x12\x18\n" + + "\acontent\x18\x01 \x01(\fR\acontent\"H\n" + + "\x17DeleteAttachmentRequest\x12\x19\n" + + "\bentry_id\x18\x01 \x01(\tR\aentryId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\x1a\n" + + "\x18DeleteAttachmentResponse\"?\n" + + "\x15CopyEntryFieldRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\"\x18\n" + + "\x16CopyEntryFieldResponse\"3\n" + + "\x17GeneratePasswordRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\"6\n" + + "\x18GeneratePasswordResponse\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword2\xf9\x11\n" + + "\fVaultService\x12a\n" + + "\x10GetSessionStatus\x12%.keepassgo.v1.GetSessionStatusRequest\x1a&.keepassgo.v1.GetSessionStatusResponse\x12L\n" + + "\tOpenVault\x12\x1e.keepassgo.v1.OpenVaultRequest\x1a\x1f.keepassgo.v1.OpenVaultResponse\x12^\n" + + "\x0fOpenRemoteVault\x12$.keepassgo.v1.OpenRemoteVaultRequest\x1a%.keepassgo.v1.OpenRemoteVaultResponse\x12L\n" + + "\tSaveVault\x12\x1e.keepassgo.v1.SaveVaultRequest\x1a\x1f.keepassgo.v1.SaveVaultResponse\x12L\n" + + "\tLockVault\x12\x1e.keepassgo.v1.LockVaultRequest\x1a\x1f.keepassgo.v1.LockVaultResponse\x12R\n" + + "\vUnlockVault\x12 .keepassgo.v1.UnlockVaultRequest\x1a!.keepassgo.v1.UnlockVaultResponse\x12R\n" + + "\vListEntries\x12 .keepassgo.v1.ListEntriesRequest\x1a!.keepassgo.v1.ListEntriesResponse\x12O\n" + + "\n" + + "ListGroups\x12\x1f.keepassgo.v1.ListGroupsRequest\x1a .keepassgo.v1.ListGroupsResponse\x12R\n" + + "\vCreateGroup\x12 .keepassgo.v1.CreateGroupRequest\x1a!.keepassgo.v1.CreateGroupResponse\x12R\n" + + "\vRenameGroup\x12 .keepassgo.v1.RenameGroupRequest\x1a!.keepassgo.v1.RenameGroupResponse\x12R\n" + + "\vUpsertEntry\x12 .keepassgo.v1.UpsertEntryRequest\x1a!.keepassgo.v1.UpsertEntryResponse\x12R\n" + + "\vDeleteEntry\x12 .keepassgo.v1.DeleteEntryRequest\x1a!.keepassgo.v1.DeleteEntryResponse\x12U\n" + + "\fRestoreEntry\x12!.keepassgo.v1.RestoreEntryRequest\x1a\".keepassgo.v1.RestoreEntryResponse\x12a\n" + + "\x10ListEntryHistory\x12%.keepassgo.v1.ListEntryHistoryRequest\x1a&.keepassgo.v1.ListEntryHistoryResponse\x12j\n" + + "\x13RestoreEntryHistory\x12(.keepassgo.v1.RestoreEntryHistoryRequest\x1a).keepassgo.v1.RestoreEntryHistoryResponse\x12X\n" + + "\rListTemplates\x12\".keepassgo.v1.ListTemplatesRequest\x1a#.keepassgo.v1.ListTemplatesResponse\x12[\n" + + "\x0eUpsertTemplate\x12#.keepassgo.v1.UpsertTemplateRequest\x1a$.keepassgo.v1.UpsertTemplateResponse\x12[\n" + + "\x0eDeleteTemplate\x12#.keepassgo.v1.DeleteTemplateRequest\x1a$.keepassgo.v1.DeleteTemplateResponse\x12j\n" + + "\x13InstantiateTemplate\x12(.keepassgo.v1.InstantiateTemplateRequest\x1a).keepassgo.v1.InstantiateTemplateResponse\x12^\n" + + "\x0fListAttachments\x12$.keepassgo.v1.ListAttachmentsRequest\x1a%.keepassgo.v1.ListAttachmentsResponse\x12a\n" + + "\x10UploadAttachment\x12%.keepassgo.v1.UploadAttachmentRequest\x1a&.keepassgo.v1.UploadAttachmentResponse\x12g\n" + + "\x12DownloadAttachment\x12'.keepassgo.v1.DownloadAttachmentRequest\x1a(.keepassgo.v1.DownloadAttachmentResponse\x12a\n" + + "\x10DeleteAttachment\x12%.keepassgo.v1.DeleteAttachmentRequest\x1a&.keepassgo.v1.DeleteAttachmentResponse\x12[\n" + + "\x0eCopyEntryField\x12#.keepassgo.v1.CopyEntryFieldRequest\x1a$.keepassgo.v1.CopyEntryFieldResponse\x12a\n" + + "\x10GeneratePassword\x12%.keepassgo.v1.GeneratePasswordRequest\x1a&.keepassgo.v1.GeneratePasswordResponseB?Z=git.julianfamily.org/keepassgo/proto/keepassgo/v1;keepassgov1b\x06proto3" + +var ( + file_proto_keepassgo_v1_keepassgo_proto_rawDescOnce sync.Once + file_proto_keepassgo_v1_keepassgo_proto_rawDescData []byte +) + +func file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP() []byte { + file_proto_keepassgo_v1_keepassgo_proto_rawDescOnce.Do(func() { + file_proto_keepassgo_v1_keepassgo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_keepassgo_v1_keepassgo_proto_rawDesc), len(file_proto_keepassgo_v1_keepassgo_proto_rawDesc))) + }) + return file_proto_keepassgo_v1_keepassgo_proto_rawDescData +} + +var file_proto_keepassgo_v1_keepassgo_proto_msgTypes = make([]protoimpl.MessageInfo, 51) +var file_proto_keepassgo_v1_keepassgo_proto_goTypes = []any{ + (*GetSessionStatusRequest)(nil), // 0: keepassgo.v1.GetSessionStatusRequest + (*GetSessionStatusResponse)(nil), // 1: keepassgo.v1.GetSessionStatusResponse + (*OpenVaultRequest)(nil), // 2: keepassgo.v1.OpenVaultRequest + (*OpenVaultResponse)(nil), // 3: keepassgo.v1.OpenVaultResponse + (*OpenRemoteVaultRequest)(nil), // 4: keepassgo.v1.OpenRemoteVaultRequest + (*OpenRemoteVaultResponse)(nil), // 5: keepassgo.v1.OpenRemoteVaultResponse + (*SaveVaultRequest)(nil), // 6: keepassgo.v1.SaveVaultRequest + (*SaveVaultResponse)(nil), // 7: keepassgo.v1.SaveVaultResponse + (*LockVaultRequest)(nil), // 8: keepassgo.v1.LockVaultRequest + (*LockVaultResponse)(nil), // 9: keepassgo.v1.LockVaultResponse + (*UnlockVaultRequest)(nil), // 10: keepassgo.v1.UnlockVaultRequest + (*UnlockVaultResponse)(nil), // 11: keepassgo.v1.UnlockVaultResponse + (*ListEntriesRequest)(nil), // 12: keepassgo.v1.ListEntriesRequest + (*Entry)(nil), // 13: keepassgo.v1.Entry + (*ListEntriesResponse)(nil), // 14: keepassgo.v1.ListEntriesResponse + (*ListGroupsRequest)(nil), // 15: keepassgo.v1.ListGroupsRequest + (*ListGroupsResponse)(nil), // 16: keepassgo.v1.ListGroupsResponse + (*CreateGroupRequest)(nil), // 17: keepassgo.v1.CreateGroupRequest + (*CreateGroupResponse)(nil), // 18: keepassgo.v1.CreateGroupResponse + (*RenameGroupRequest)(nil), // 19: keepassgo.v1.RenameGroupRequest + (*RenameGroupResponse)(nil), // 20: keepassgo.v1.RenameGroupResponse + (*UpsertEntryRequest)(nil), // 21: keepassgo.v1.UpsertEntryRequest + (*UpsertEntryResponse)(nil), // 22: keepassgo.v1.UpsertEntryResponse + (*DeleteEntryRequest)(nil), // 23: keepassgo.v1.DeleteEntryRequest + (*DeleteEntryResponse)(nil), // 24: keepassgo.v1.DeleteEntryResponse + (*RestoreEntryRequest)(nil), // 25: keepassgo.v1.RestoreEntryRequest + (*RestoreEntryResponse)(nil), // 26: keepassgo.v1.RestoreEntryResponse + (*ListEntryHistoryRequest)(nil), // 27: keepassgo.v1.ListEntryHistoryRequest + (*ListEntryHistoryResponse)(nil), // 28: keepassgo.v1.ListEntryHistoryResponse + (*RestoreEntryHistoryRequest)(nil), // 29: keepassgo.v1.RestoreEntryHistoryRequest + (*RestoreEntryHistoryResponse)(nil), // 30: keepassgo.v1.RestoreEntryHistoryResponse + (*ListTemplatesRequest)(nil), // 31: keepassgo.v1.ListTemplatesRequest + (*ListTemplatesResponse)(nil), // 32: keepassgo.v1.ListTemplatesResponse + (*UpsertTemplateRequest)(nil), // 33: keepassgo.v1.UpsertTemplateRequest + (*UpsertTemplateResponse)(nil), // 34: keepassgo.v1.UpsertTemplateResponse + (*DeleteTemplateRequest)(nil), // 35: keepassgo.v1.DeleteTemplateRequest + (*DeleteTemplateResponse)(nil), // 36: keepassgo.v1.DeleteTemplateResponse + (*InstantiateTemplateRequest)(nil), // 37: keepassgo.v1.InstantiateTemplateRequest + (*InstantiateTemplateResponse)(nil), // 38: keepassgo.v1.InstantiateTemplateResponse + (*ListAttachmentsRequest)(nil), // 39: keepassgo.v1.ListAttachmentsRequest + (*ListAttachmentsResponse)(nil), // 40: keepassgo.v1.ListAttachmentsResponse + (*UploadAttachmentRequest)(nil), // 41: keepassgo.v1.UploadAttachmentRequest + (*UploadAttachmentResponse)(nil), // 42: keepassgo.v1.UploadAttachmentResponse + (*DownloadAttachmentRequest)(nil), // 43: keepassgo.v1.DownloadAttachmentRequest + (*DownloadAttachmentResponse)(nil), // 44: keepassgo.v1.DownloadAttachmentResponse + (*DeleteAttachmentRequest)(nil), // 45: keepassgo.v1.DeleteAttachmentRequest + (*DeleteAttachmentResponse)(nil), // 46: keepassgo.v1.DeleteAttachmentResponse + (*CopyEntryFieldRequest)(nil), // 47: keepassgo.v1.CopyEntryFieldRequest + (*CopyEntryFieldResponse)(nil), // 48: keepassgo.v1.CopyEntryFieldResponse + (*GeneratePasswordRequest)(nil), // 49: keepassgo.v1.GeneratePasswordRequest + (*GeneratePasswordResponse)(nil), // 50: keepassgo.v1.GeneratePasswordResponse +} +var file_proto_keepassgo_v1_keepassgo_proto_depIdxs = []int32{ + 13, // 0: keepassgo.v1.ListEntriesResponse.entries:type_name -> keepassgo.v1.Entry + 13, // 1: keepassgo.v1.UpsertEntryRequest.entry:type_name -> keepassgo.v1.Entry + 13, // 2: keepassgo.v1.UpsertEntryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 3: keepassgo.v1.RestoreEntryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 4: keepassgo.v1.ListEntryHistoryResponse.entries:type_name -> keepassgo.v1.Entry + 13, // 5: keepassgo.v1.RestoreEntryHistoryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 6: keepassgo.v1.ListTemplatesResponse.templates:type_name -> keepassgo.v1.Entry + 13, // 7: keepassgo.v1.UpsertTemplateRequest.template:type_name -> keepassgo.v1.Entry + 13, // 8: keepassgo.v1.UpsertTemplateResponse.template:type_name -> keepassgo.v1.Entry + 13, // 9: keepassgo.v1.InstantiateTemplateRequest.overrides:type_name -> keepassgo.v1.Entry + 13, // 10: keepassgo.v1.InstantiateTemplateResponse.entry:type_name -> keepassgo.v1.Entry + 0, // 11: keepassgo.v1.VaultService.GetSessionStatus:input_type -> keepassgo.v1.GetSessionStatusRequest + 2, // 12: keepassgo.v1.VaultService.OpenVault:input_type -> keepassgo.v1.OpenVaultRequest + 4, // 13: keepassgo.v1.VaultService.OpenRemoteVault:input_type -> keepassgo.v1.OpenRemoteVaultRequest + 6, // 14: keepassgo.v1.VaultService.SaveVault:input_type -> keepassgo.v1.SaveVaultRequest + 8, // 15: keepassgo.v1.VaultService.LockVault:input_type -> keepassgo.v1.LockVaultRequest + 10, // 16: keepassgo.v1.VaultService.UnlockVault:input_type -> keepassgo.v1.UnlockVaultRequest + 12, // 17: keepassgo.v1.VaultService.ListEntries:input_type -> keepassgo.v1.ListEntriesRequest + 15, // 18: keepassgo.v1.VaultService.ListGroups:input_type -> keepassgo.v1.ListGroupsRequest + 17, // 19: keepassgo.v1.VaultService.CreateGroup:input_type -> keepassgo.v1.CreateGroupRequest + 19, // 20: keepassgo.v1.VaultService.RenameGroup:input_type -> keepassgo.v1.RenameGroupRequest + 21, // 21: keepassgo.v1.VaultService.UpsertEntry:input_type -> keepassgo.v1.UpsertEntryRequest + 23, // 22: keepassgo.v1.VaultService.DeleteEntry:input_type -> keepassgo.v1.DeleteEntryRequest + 25, // 23: keepassgo.v1.VaultService.RestoreEntry:input_type -> keepassgo.v1.RestoreEntryRequest + 27, // 24: keepassgo.v1.VaultService.ListEntryHistory:input_type -> keepassgo.v1.ListEntryHistoryRequest + 29, // 25: keepassgo.v1.VaultService.RestoreEntryHistory:input_type -> keepassgo.v1.RestoreEntryHistoryRequest + 31, // 26: keepassgo.v1.VaultService.ListTemplates:input_type -> keepassgo.v1.ListTemplatesRequest + 33, // 27: keepassgo.v1.VaultService.UpsertTemplate:input_type -> keepassgo.v1.UpsertTemplateRequest + 35, // 28: keepassgo.v1.VaultService.DeleteTemplate:input_type -> keepassgo.v1.DeleteTemplateRequest + 37, // 29: keepassgo.v1.VaultService.InstantiateTemplate:input_type -> keepassgo.v1.InstantiateTemplateRequest + 39, // 30: keepassgo.v1.VaultService.ListAttachments:input_type -> keepassgo.v1.ListAttachmentsRequest + 41, // 31: keepassgo.v1.VaultService.UploadAttachment:input_type -> keepassgo.v1.UploadAttachmentRequest + 43, // 32: keepassgo.v1.VaultService.DownloadAttachment:input_type -> keepassgo.v1.DownloadAttachmentRequest + 45, // 33: keepassgo.v1.VaultService.DeleteAttachment:input_type -> keepassgo.v1.DeleteAttachmentRequest + 47, // 34: keepassgo.v1.VaultService.CopyEntryField:input_type -> keepassgo.v1.CopyEntryFieldRequest + 49, // 35: keepassgo.v1.VaultService.GeneratePassword:input_type -> keepassgo.v1.GeneratePasswordRequest + 1, // 36: keepassgo.v1.VaultService.GetSessionStatus:output_type -> keepassgo.v1.GetSessionStatusResponse + 3, // 37: keepassgo.v1.VaultService.OpenVault:output_type -> keepassgo.v1.OpenVaultResponse + 5, // 38: keepassgo.v1.VaultService.OpenRemoteVault:output_type -> keepassgo.v1.OpenRemoteVaultResponse + 7, // 39: keepassgo.v1.VaultService.SaveVault:output_type -> keepassgo.v1.SaveVaultResponse + 9, // 40: keepassgo.v1.VaultService.LockVault:output_type -> keepassgo.v1.LockVaultResponse + 11, // 41: keepassgo.v1.VaultService.UnlockVault:output_type -> keepassgo.v1.UnlockVaultResponse + 14, // 42: keepassgo.v1.VaultService.ListEntries:output_type -> keepassgo.v1.ListEntriesResponse + 16, // 43: keepassgo.v1.VaultService.ListGroups:output_type -> keepassgo.v1.ListGroupsResponse + 18, // 44: keepassgo.v1.VaultService.CreateGroup:output_type -> keepassgo.v1.CreateGroupResponse + 20, // 45: keepassgo.v1.VaultService.RenameGroup:output_type -> keepassgo.v1.RenameGroupResponse + 22, // 46: keepassgo.v1.VaultService.UpsertEntry:output_type -> keepassgo.v1.UpsertEntryResponse + 24, // 47: keepassgo.v1.VaultService.DeleteEntry:output_type -> keepassgo.v1.DeleteEntryResponse + 26, // 48: keepassgo.v1.VaultService.RestoreEntry:output_type -> keepassgo.v1.RestoreEntryResponse + 28, // 49: keepassgo.v1.VaultService.ListEntryHistory:output_type -> keepassgo.v1.ListEntryHistoryResponse + 30, // 50: keepassgo.v1.VaultService.RestoreEntryHistory:output_type -> keepassgo.v1.RestoreEntryHistoryResponse + 32, // 51: keepassgo.v1.VaultService.ListTemplates:output_type -> keepassgo.v1.ListTemplatesResponse + 34, // 52: keepassgo.v1.VaultService.UpsertTemplate:output_type -> keepassgo.v1.UpsertTemplateResponse + 36, // 53: keepassgo.v1.VaultService.DeleteTemplate:output_type -> keepassgo.v1.DeleteTemplateResponse + 38, // 54: keepassgo.v1.VaultService.InstantiateTemplate:output_type -> keepassgo.v1.InstantiateTemplateResponse + 40, // 55: keepassgo.v1.VaultService.ListAttachments:output_type -> keepassgo.v1.ListAttachmentsResponse + 42, // 56: keepassgo.v1.VaultService.UploadAttachment:output_type -> keepassgo.v1.UploadAttachmentResponse + 44, // 57: keepassgo.v1.VaultService.DownloadAttachment:output_type -> keepassgo.v1.DownloadAttachmentResponse + 46, // 58: keepassgo.v1.VaultService.DeleteAttachment:output_type -> keepassgo.v1.DeleteAttachmentResponse + 48, // 59: keepassgo.v1.VaultService.CopyEntryField:output_type -> keepassgo.v1.CopyEntryFieldResponse + 50, // 60: keepassgo.v1.VaultService.GeneratePassword:output_type -> keepassgo.v1.GeneratePasswordResponse + 36, // [36:61] is the sub-list for method output_type + 11, // [11:36] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_proto_keepassgo_v1_keepassgo_proto_init() } +func file_proto_keepassgo_v1_keepassgo_proto_init() { + if File_proto_keepassgo_v1_keepassgo_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_keepassgo_v1_keepassgo_proto_rawDesc), len(file_proto_keepassgo_v1_keepassgo_proto_rawDesc)), + NumEnums: 0, + NumMessages: 51, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_keepassgo_v1_keepassgo_proto_goTypes, + DependencyIndexes: file_proto_keepassgo_v1_keepassgo_proto_depIdxs, + MessageInfos: file_proto_keepassgo_v1_keepassgo_proto_msgTypes, + }.Build() + File_proto_keepassgo_v1_keepassgo_proto = out.File + file_proto_keepassgo_v1_keepassgo_proto_goTypes = nil + file_proto_keepassgo_v1_keepassgo_proto_depIdxs = nil +} diff --git a/proto/keepassgo/v1/keepassgo.proto b/proto/keepassgo/v1/keepassgo.proto new file mode 100644 index 0000000..4005184 --- /dev/null +++ b/proto/keepassgo/v1/keepassgo.proto @@ -0,0 +1,229 @@ +syntax = "proto3"; + +package keepassgo.v1; + +option go_package = "git.julianfamily.org/keepassgo/proto/keepassgo/v1;keepassgov1"; + +service VaultService { + rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse); + rpc OpenVault(OpenVaultRequest) returns (OpenVaultResponse); + rpc OpenRemoteVault(OpenRemoteVaultRequest) returns (OpenRemoteVaultResponse); + rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse); + rpc LockVault(LockVaultRequest) returns (LockVaultResponse); + rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse); + rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse); + rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse); + rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse); + rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse); + rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse); + rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse); + rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse); + rpc ListEntryHistory(ListEntryHistoryRequest) returns (ListEntryHistoryResponse); + rpc RestoreEntryHistory(RestoreEntryHistoryRequest) returns (RestoreEntryHistoryResponse); + rpc ListTemplates(ListTemplatesRequest) returns (ListTemplatesResponse); + rpc UpsertTemplate(UpsertTemplateRequest) returns (UpsertTemplateResponse); + rpc DeleteTemplate(DeleteTemplateRequest) returns (DeleteTemplateResponse); + rpc InstantiateTemplate(InstantiateTemplateRequest) returns (InstantiateTemplateResponse); + rpc ListAttachments(ListAttachmentsRequest) returns (ListAttachmentsResponse); + rpc UploadAttachment(UploadAttachmentRequest) returns (UploadAttachmentResponse); + rpc DownloadAttachment(DownloadAttachmentRequest) returns (DownloadAttachmentResponse); + rpc DeleteAttachment(DeleteAttachmentRequest) returns (DeleteAttachmentResponse); + rpc CopyEntryField(CopyEntryFieldRequest) returns (CopyEntryFieldResponse); + rpc GeneratePassword(GeneratePasswordRequest) returns (GeneratePasswordResponse); +} + +message GetSessionStatusRequest {} + +message GetSessionStatusResponse { + bool locked = 1; + bool dirty = 2; + uint32 entry_count = 3; +} + +message OpenVaultRequest { + string path = 1; + string password = 2; + bytes key_file_data = 3; +} + +message OpenVaultResponse {} + +message OpenRemoteVaultRequest { + string base_url = 1; + string path = 2; + string username = 3; + string password = 4; + string master_password = 5; + bytes key_file_data = 6; +} + +message OpenRemoteVaultResponse {} + +message SaveVaultRequest {} + +message SaveVaultResponse {} + +message LockVaultRequest {} + +message LockVaultResponse {} + +message UnlockVaultRequest {} + +message UnlockVaultResponse {} + +message ListEntriesRequest { + repeated string path = 1; + string query = 2; +} + +message Entry { + string id = 1; + string title = 2; + string username = 3; + string password = 4; + string url = 5; + string notes = 6; + repeated string tags = 7; + repeated string path = 8; +} + +message ListEntriesResponse { + repeated Entry entries = 1; +} + +message ListGroupsRequest { + repeated string path = 1; +} + +message ListGroupsResponse { + repeated string names = 1; +} + +message CreateGroupRequest { + repeated string parent_path = 1; + string name = 2; +} + +message CreateGroupResponse {} + +message RenameGroupRequest { + repeated string path = 1; + string new_name = 2; +} + +message RenameGroupResponse {} + +message UpsertEntryRequest { + Entry entry = 1; +} + +message UpsertEntryResponse { + Entry entry = 1; +} + +message DeleteEntryRequest { + string id = 1; +} + +message DeleteEntryResponse {} + +message RestoreEntryRequest { + string id = 1; +} + +message RestoreEntryResponse { + Entry entry = 1; +} + +message ListEntryHistoryRequest { + string id = 1; +} + +message ListEntryHistoryResponse { + repeated Entry entries = 1; +} + +message RestoreEntryHistoryRequest { + string id = 1; + uint32 history_index = 2; +} + +message RestoreEntryHistoryResponse { + Entry entry = 1; +} + +message ListTemplatesRequest {} + +message ListTemplatesResponse { + repeated Entry templates = 1; +} + +message UpsertTemplateRequest { + Entry template = 1; +} + +message UpsertTemplateResponse { + Entry template = 1; +} + +message DeleteTemplateRequest { + string id = 1; +} + +message DeleteTemplateResponse {} + +message InstantiateTemplateRequest { + string template_id = 1; + Entry overrides = 2; +} + +message InstantiateTemplateResponse { + Entry entry = 1; +} + +message ListAttachmentsRequest { + string entry_id = 1; +} + +message ListAttachmentsResponse { + repeated string names = 1; +} + +message UploadAttachmentRequest { + string entry_id = 1; + string name = 2; + bytes content = 3; +} + +message UploadAttachmentResponse {} + +message DownloadAttachmentRequest { + string entry_id = 1; + string name = 2; +} + +message DownloadAttachmentResponse { + bytes content = 1; +} + +message DeleteAttachmentRequest { + string entry_id = 1; + string name = 2; +} + +message DeleteAttachmentResponse {} + +message CopyEntryFieldRequest { + string id = 1; + string target = 2; +} + +message CopyEntryFieldResponse {} + +message GeneratePasswordRequest { + string profile = 1; +} + +message GeneratePasswordResponse { + string password = 1; +} diff --git a/proto/keepassgo/v1/keepassgo_grpc.pb.go b/proto/keepassgo/v1/keepassgo_grpc.pb.go new file mode 100644 index 0000000..2eb4e34 --- /dev/null +++ b/proto/keepassgo/v1/keepassgo_grpc.pb.go @@ -0,0 +1,1033 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v6.33.1 +// source: proto/keepassgo/v1/keepassgo.proto + +package keepassgov1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus" + VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault" + VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault" + VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault" + VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault" + VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault" + VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries" + VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups" + VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup" + VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup" + VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry" + VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry" + VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry" + VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory" + VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory" + VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates" + VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate" + VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate" + VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate" + VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments" + VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment" + VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment" + VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment" + VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField" + VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword" +) + +// VaultServiceClient is the client API for VaultService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type VaultServiceClient interface { + GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) + OpenVault(ctx context.Context, in *OpenVaultRequest, opts ...grpc.CallOption) (*OpenVaultResponse, error) + OpenRemoteVault(ctx context.Context, in *OpenRemoteVaultRequest, opts ...grpc.CallOption) (*OpenRemoteVaultResponse, error) + SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error) + LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error) + UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error) + ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) + ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error) + CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error) + RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error) + UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) + DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error) + RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error) + ListEntryHistory(ctx context.Context, in *ListEntryHistoryRequest, opts ...grpc.CallOption) (*ListEntryHistoryResponse, error) + RestoreEntryHistory(ctx context.Context, in *RestoreEntryHistoryRequest, opts ...grpc.CallOption) (*RestoreEntryHistoryResponse, error) + ListTemplates(ctx context.Context, in *ListTemplatesRequest, opts ...grpc.CallOption) (*ListTemplatesResponse, error) + UpsertTemplate(ctx context.Context, in *UpsertTemplateRequest, opts ...grpc.CallOption) (*UpsertTemplateResponse, error) + DeleteTemplate(ctx context.Context, in *DeleteTemplateRequest, opts ...grpc.CallOption) (*DeleteTemplateResponse, error) + InstantiateTemplate(ctx context.Context, in *InstantiateTemplateRequest, opts ...grpc.CallOption) (*InstantiateTemplateResponse, error) + ListAttachments(ctx context.Context, in *ListAttachmentsRequest, opts ...grpc.CallOption) (*ListAttachmentsResponse, error) + UploadAttachment(ctx context.Context, in *UploadAttachmentRequest, opts ...grpc.CallOption) (*UploadAttachmentResponse, error) + DownloadAttachment(ctx context.Context, in *DownloadAttachmentRequest, opts ...grpc.CallOption) (*DownloadAttachmentResponse, error) + DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*DeleteAttachmentResponse, error) + CopyEntryField(ctx context.Context, in *CopyEntryFieldRequest, opts ...grpc.CallOption) (*CopyEntryFieldResponse, error) + GeneratePassword(ctx context.Context, in *GeneratePasswordRequest, opts ...grpc.CallOption) (*GeneratePasswordResponse, error) +} + +type vaultServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewVaultServiceClient(cc grpc.ClientConnInterface) VaultServiceClient { + return &vaultServiceClient{cc} +} + +func (c *vaultServiceClient) GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSessionStatusResponse) + err := c.cc.Invoke(ctx, VaultService_GetSessionStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) OpenVault(ctx context.Context, in *OpenVaultRequest, opts ...grpc.CallOption) (*OpenVaultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OpenVaultResponse) + err := c.cc.Invoke(ctx, VaultService_OpenVault_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) OpenRemoteVault(ctx context.Context, in *OpenRemoteVaultRequest, opts ...grpc.CallOption) (*OpenRemoteVaultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OpenRemoteVaultResponse) + err := c.cc.Invoke(ctx, VaultService_OpenRemoteVault_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SaveVaultResponse) + err := c.cc.Invoke(ctx, VaultService_SaveVault_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LockVaultResponse) + err := c.cc.Invoke(ctx, VaultService_LockVault_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UnlockVaultResponse) + err := c.cc.Invoke(ctx, VaultService_UnlockVault_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListEntriesResponse) + err := c.cc.Invoke(ctx, VaultService_ListEntries_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListGroupsResponse) + err := c.cc.Invoke(ctx, VaultService_ListGroups_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateGroupResponse) + err := c.cc.Invoke(ctx, VaultService_CreateGroup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenameGroupResponse) + err := c.cc.Invoke(ctx, VaultService_RenameGroup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpsertEntryResponse) + err := c.cc.Invoke(ctx, VaultService_UpsertEntry_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteEntryResponse) + err := c.cc.Invoke(ctx, VaultService_DeleteEntry_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RestoreEntryResponse) + err := c.cc.Invoke(ctx, VaultService_RestoreEntry_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) ListEntryHistory(ctx context.Context, in *ListEntryHistoryRequest, opts ...grpc.CallOption) (*ListEntryHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListEntryHistoryResponse) + err := c.cc.Invoke(ctx, VaultService_ListEntryHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) RestoreEntryHistory(ctx context.Context, in *RestoreEntryHistoryRequest, opts ...grpc.CallOption) (*RestoreEntryHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RestoreEntryHistoryResponse) + err := c.cc.Invoke(ctx, VaultService_RestoreEntryHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) ListTemplates(ctx context.Context, in *ListTemplatesRequest, opts ...grpc.CallOption) (*ListTemplatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListTemplatesResponse) + err := c.cc.Invoke(ctx, VaultService_ListTemplates_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) UpsertTemplate(ctx context.Context, in *UpsertTemplateRequest, opts ...grpc.CallOption) (*UpsertTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpsertTemplateResponse) + err := c.cc.Invoke(ctx, VaultService_UpsertTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) DeleteTemplate(ctx context.Context, in *DeleteTemplateRequest, opts ...grpc.CallOption) (*DeleteTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteTemplateResponse) + err := c.cc.Invoke(ctx, VaultService_DeleteTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) InstantiateTemplate(ctx context.Context, in *InstantiateTemplateRequest, opts ...grpc.CallOption) (*InstantiateTemplateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InstantiateTemplateResponse) + err := c.cc.Invoke(ctx, VaultService_InstantiateTemplate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) ListAttachments(ctx context.Context, in *ListAttachmentsRequest, opts ...grpc.CallOption) (*ListAttachmentsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListAttachmentsResponse) + err := c.cc.Invoke(ctx, VaultService_ListAttachments_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) UploadAttachment(ctx context.Context, in *UploadAttachmentRequest, opts ...grpc.CallOption) (*UploadAttachmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UploadAttachmentResponse) + err := c.cc.Invoke(ctx, VaultService_UploadAttachment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) DownloadAttachment(ctx context.Context, in *DownloadAttachmentRequest, opts ...grpc.CallOption) (*DownloadAttachmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DownloadAttachmentResponse) + err := c.cc.Invoke(ctx, VaultService_DownloadAttachment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) DeleteAttachment(ctx context.Context, in *DeleteAttachmentRequest, opts ...grpc.CallOption) (*DeleteAttachmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteAttachmentResponse) + err := c.cc.Invoke(ctx, VaultService_DeleteAttachment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) CopyEntryField(ctx context.Context, in *CopyEntryFieldRequest, opts ...grpc.CallOption) (*CopyEntryFieldResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CopyEntryFieldResponse) + err := c.cc.Invoke(ctx, VaultService_CopyEntryField_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) GeneratePassword(ctx context.Context, in *GeneratePasswordRequest, opts ...grpc.CallOption) (*GeneratePasswordResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GeneratePasswordResponse) + err := c.cc.Invoke(ctx, VaultService_GeneratePassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VaultServiceServer is the server API for VaultService service. +// All implementations must embed UnimplementedVaultServiceServer +// for forward compatibility. +type VaultServiceServer interface { + GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) + OpenVault(context.Context, *OpenVaultRequest) (*OpenVaultResponse, error) + OpenRemoteVault(context.Context, *OpenRemoteVaultRequest) (*OpenRemoteVaultResponse, error) + SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error) + LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error) + UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) + ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) + ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error) + CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error) + RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) + UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) + DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error) + RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error) + ListEntryHistory(context.Context, *ListEntryHistoryRequest) (*ListEntryHistoryResponse, error) + RestoreEntryHistory(context.Context, *RestoreEntryHistoryRequest) (*RestoreEntryHistoryResponse, error) + ListTemplates(context.Context, *ListTemplatesRequest) (*ListTemplatesResponse, error) + UpsertTemplate(context.Context, *UpsertTemplateRequest) (*UpsertTemplateResponse, error) + DeleteTemplate(context.Context, *DeleteTemplateRequest) (*DeleteTemplateResponse, error) + InstantiateTemplate(context.Context, *InstantiateTemplateRequest) (*InstantiateTemplateResponse, error) + ListAttachments(context.Context, *ListAttachmentsRequest) (*ListAttachmentsResponse, error) + UploadAttachment(context.Context, *UploadAttachmentRequest) (*UploadAttachmentResponse, error) + DownloadAttachment(context.Context, *DownloadAttachmentRequest) (*DownloadAttachmentResponse, error) + DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*DeleteAttachmentResponse, error) + CopyEntryField(context.Context, *CopyEntryFieldRequest) (*CopyEntryFieldResponse, error) + GeneratePassword(context.Context, *GeneratePasswordRequest) (*GeneratePasswordResponse, error) + mustEmbedUnimplementedVaultServiceServer() +} + +// UnimplementedVaultServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedVaultServiceServer struct{} + +func (UnimplementedVaultServiceServer) GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSessionStatus not implemented") +} +func (UnimplementedVaultServiceServer) OpenVault(context.Context, *OpenVaultRequest) (*OpenVaultResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method OpenVault not implemented") +} +func (UnimplementedVaultServiceServer) OpenRemoteVault(context.Context, *OpenRemoteVaultRequest) (*OpenRemoteVaultResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method OpenRemoteVault not implemented") +} +func (UnimplementedVaultServiceServer) SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SaveVault not implemented") +} +func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method LockVault not implemented") +} +func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented") +} +func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented") +} +func (UnimplementedVaultServiceServer) ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListGroups not implemented") +} +func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateGroup not implemented") +} +func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented") +} +func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented") +} +func (UnimplementedVaultServiceServer) DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteEntry not implemented") +} +func (UnimplementedVaultServiceServer) RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RestoreEntry not implemented") +} +func (UnimplementedVaultServiceServer) ListEntryHistory(context.Context, *ListEntryHistoryRequest) (*ListEntryHistoryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListEntryHistory not implemented") +} +func (UnimplementedVaultServiceServer) RestoreEntryHistory(context.Context, *RestoreEntryHistoryRequest) (*RestoreEntryHistoryResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RestoreEntryHistory not implemented") +} +func (UnimplementedVaultServiceServer) ListTemplates(context.Context, *ListTemplatesRequest) (*ListTemplatesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTemplates not implemented") +} +func (UnimplementedVaultServiceServer) UpsertTemplate(context.Context, *UpsertTemplateRequest) (*UpsertTemplateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpsertTemplate not implemented") +} +func (UnimplementedVaultServiceServer) DeleteTemplate(context.Context, *DeleteTemplateRequest) (*DeleteTemplateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteTemplate not implemented") +} +func (UnimplementedVaultServiceServer) InstantiateTemplate(context.Context, *InstantiateTemplateRequest) (*InstantiateTemplateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method InstantiateTemplate not implemented") +} +func (UnimplementedVaultServiceServer) ListAttachments(context.Context, *ListAttachmentsRequest) (*ListAttachmentsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListAttachments not implemented") +} +func (UnimplementedVaultServiceServer) UploadAttachment(context.Context, *UploadAttachmentRequest) (*UploadAttachmentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UploadAttachment not implemented") +} +func (UnimplementedVaultServiceServer) DownloadAttachment(context.Context, *DownloadAttachmentRequest) (*DownloadAttachmentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DownloadAttachment not implemented") +} +func (UnimplementedVaultServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*DeleteAttachmentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteAttachment not implemented") +} +func (UnimplementedVaultServiceServer) CopyEntryField(context.Context, *CopyEntryFieldRequest) (*CopyEntryFieldResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CopyEntryField not implemented") +} +func (UnimplementedVaultServiceServer) GeneratePassword(context.Context, *GeneratePasswordRequest) (*GeneratePasswordResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GeneratePassword not implemented") +} +func (UnimplementedVaultServiceServer) mustEmbedUnimplementedVaultServiceServer() {} +func (UnimplementedVaultServiceServer) testEmbeddedByValue() {} + +// UnsafeVaultServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to VaultServiceServer will +// result in compilation errors. +type UnsafeVaultServiceServer interface { + mustEmbedUnimplementedVaultServiceServer() +} + +func RegisterVaultServiceServer(s grpc.ServiceRegistrar, srv VaultServiceServer) { + // If the following call pancis, it indicates UnimplementedVaultServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&VaultService_ServiceDesc, srv) +} + +func _VaultService_GetSessionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSessionStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).GetSessionStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_GetSessionStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).GetSessionStatus(ctx, req.(*GetSessionStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_OpenVault_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenVaultRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).OpenVault(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_OpenVault_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).OpenVault(ctx, req.(*OpenVaultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_OpenRemoteVault_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenRemoteVaultRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).OpenRemoteVault(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_OpenRemoteVault_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).OpenRemoteVault(ctx, req.(*OpenRemoteVaultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_SaveVault_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SaveVaultRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).SaveVault(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_SaveVault_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).SaveVault(ctx, req.(*SaveVaultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_LockVault_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockVaultRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).LockVault(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_LockVault_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).LockVault(ctx, req.(*LockVaultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnlockVaultRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).UnlockVault(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_UnlockVault_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).UnlockVault(ctx, req.(*UnlockVaultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEntriesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).ListEntries(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_ListEntries_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).ListEntries(ctx, req.(*ListEntriesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_ListGroups_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListGroupsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).ListGroups(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_ListGroups_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).ListGroups(ctx, req.(*ListGroupsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_CreateGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateGroupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).CreateGroup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_CreateGroup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).CreateGroup(ctx, req.(*CreateGroupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenameGroupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).RenameGroup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_RenameGroup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).RenameGroup(ctx, req.(*RenameGroupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpsertEntryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).UpsertEntry(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_UpsertEntry_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).UpsertEntry(ctx, req.(*UpsertEntryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_DeleteEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteEntryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).DeleteEntry(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_DeleteEntry_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).DeleteEntry(ctx, req.(*DeleteEntryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_RestoreEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RestoreEntryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).RestoreEntry(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_RestoreEntry_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).RestoreEntry(ctx, req.(*RestoreEntryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_ListEntryHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEntryHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).ListEntryHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_ListEntryHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).ListEntryHistory(ctx, req.(*ListEntryHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_RestoreEntryHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RestoreEntryHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).RestoreEntryHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_RestoreEntryHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).RestoreEntryHistory(ctx, req.(*RestoreEntryHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_ListTemplates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTemplatesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).ListTemplates(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_ListTemplates_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).ListTemplates(ctx, req.(*ListTemplatesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_UpsertTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpsertTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).UpsertTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_UpsertTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).UpsertTemplate(ctx, req.(*UpsertTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_DeleteTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).DeleteTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_DeleteTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).DeleteTemplate(ctx, req.(*DeleteTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_InstantiateTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InstantiateTemplateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).InstantiateTemplate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_InstantiateTemplate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).InstantiateTemplate(ctx, req.(*InstantiateTemplateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_ListAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAttachmentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).ListAttachments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_ListAttachments_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).ListAttachments(ctx, req.(*ListAttachmentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_UploadAttachment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UploadAttachmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).UploadAttachment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_UploadAttachment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).UploadAttachment(ctx, req.(*UploadAttachmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_DownloadAttachment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DownloadAttachmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).DownloadAttachment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_DownloadAttachment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).DownloadAttachment(ctx, req.(*DownloadAttachmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_DeleteAttachment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteAttachmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).DeleteAttachment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_DeleteAttachment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).DeleteAttachment(ctx, req.(*DeleteAttachmentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_CopyEntryField_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CopyEntryFieldRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).CopyEntryField(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_CopyEntryField_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).CopyEntryField(ctx, req.(*CopyEntryFieldRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_GeneratePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GeneratePasswordRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).GeneratePassword(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_GeneratePassword_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).GeneratePassword(ctx, req.(*GeneratePasswordRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// VaultService_ServiceDesc is the grpc.ServiceDesc for VaultService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var VaultService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "keepassgo.v1.VaultService", + HandlerType: (*VaultServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetSessionStatus", + Handler: _VaultService_GetSessionStatus_Handler, + }, + { + MethodName: "OpenVault", + Handler: _VaultService_OpenVault_Handler, + }, + { + MethodName: "OpenRemoteVault", + Handler: _VaultService_OpenRemoteVault_Handler, + }, + { + MethodName: "SaveVault", + Handler: _VaultService_SaveVault_Handler, + }, + { + MethodName: "LockVault", + Handler: _VaultService_LockVault_Handler, + }, + { + MethodName: "UnlockVault", + Handler: _VaultService_UnlockVault_Handler, + }, + { + MethodName: "ListEntries", + Handler: _VaultService_ListEntries_Handler, + }, + { + MethodName: "ListGroups", + Handler: _VaultService_ListGroups_Handler, + }, + { + MethodName: "CreateGroup", + Handler: _VaultService_CreateGroup_Handler, + }, + { + MethodName: "RenameGroup", + Handler: _VaultService_RenameGroup_Handler, + }, + { + MethodName: "UpsertEntry", + Handler: _VaultService_UpsertEntry_Handler, + }, + { + MethodName: "DeleteEntry", + Handler: _VaultService_DeleteEntry_Handler, + }, + { + MethodName: "RestoreEntry", + Handler: _VaultService_RestoreEntry_Handler, + }, + { + MethodName: "ListEntryHistory", + Handler: _VaultService_ListEntryHistory_Handler, + }, + { + MethodName: "RestoreEntryHistory", + Handler: _VaultService_RestoreEntryHistory_Handler, + }, + { + MethodName: "ListTemplates", + Handler: _VaultService_ListTemplates_Handler, + }, + { + MethodName: "UpsertTemplate", + Handler: _VaultService_UpsertTemplate_Handler, + }, + { + MethodName: "DeleteTemplate", + Handler: _VaultService_DeleteTemplate_Handler, + }, + { + MethodName: "InstantiateTemplate", + Handler: _VaultService_InstantiateTemplate_Handler, + }, + { + MethodName: "ListAttachments", + Handler: _VaultService_ListAttachments_Handler, + }, + { + MethodName: "UploadAttachment", + Handler: _VaultService_UploadAttachment_Handler, + }, + { + MethodName: "DownloadAttachment", + Handler: _VaultService_DownloadAttachment_Handler, + }, + { + MethodName: "DeleteAttachment", + Handler: _VaultService_DeleteAttachment_Handler, + }, + { + MethodName: "CopyEntryField", + Handler: _VaultService_CopyEntryField_Handler, + }, + { + MethodName: "GeneratePassword", + Handler: _VaultService_GeneratePassword_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/keepassgo/v1/keepassgo.proto", +} diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..1496d89 --- /dev/null +++ b/session/session.go @@ -0,0 +1,180 @@ +package session + +import ( + "bytes" + "errors" + "fmt" + "os" + + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" +) + +var ( + ErrLocked = errors.New("vault is locked") + ErrNoPath = errors.New("no vault path configured") +) + +type Manager struct { + model vault.Model + config *vault.KDBXConfig + path string + key vault.MasterKey + locked bool + encoded []byte + remoteClient *webdav.Client + remotePath string + remoteVersion webdav.Version +} + +func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil { + return fmt.Errorf("encode new vault: %w", err) + } + + m.model = model + m.key = key + m.encoded = encoded.Bytes() + m.locked = false + return nil +} + +func (m *Manager) Open(path string, key vault.MasterKey) error { + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + + m.model = model + m.config = config + m.path = path + m.key = key + m.encoded = content + m.locked = false + return nil +} + +func (m *Manager) Save() error { + if m.remoteClient != nil && m.remotePath != "" { + return m.SaveRemote() + } + + if m.path == "" { + return ErrNoPath + } + + return m.saveToPath(m.path) +} + +func (m *Manager) OpenRemote(client webdav.Client, path string, key vault.MasterKey) error { + content, version, err := client.Open(path) + if err != nil { + return fmt.Errorf("open remote %s: %w", path, err) + } + + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return fmt.Errorf("decode remote %s: %w", path, err) + } + + m.model = model + m.config = config + m.key = key + m.encoded = content + m.locked = false + m.remoteClient = &client + m.remotePath = path + m.remoteVersion = version + return nil +} + +func (m *Manager) SaveRemote() error { + if m.remoteClient == nil || m.remotePath == "" { + return ErrNoPath + } + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { + return fmt.Errorf("encode vault: %w", err) + } + + version, err := m.remoteClient.Save(m.remotePath, bytes.NewReader(encoded.Bytes()), m.remoteVersion) + if err != nil { + return fmt.Errorf("save remote %s: %w", m.remotePath, err) + } + + m.encoded = encoded.Bytes() + m.remoteVersion = version + return nil +} + +func (m *Manager) SaveAs(path string) error { + if err := m.saveToPath(path); err != nil { + return err + } + + m.path = path + return nil +} + +func (m *Manager) Replace(model vault.Model) { + m.model = model + m.locked = false +} + +func (m *Manager) Current() (vault.Model, error) { + if m.locked { + return vault.Model{}, ErrLocked + } + + return m.model, nil +} + +func (m *Manager) Lock() error { + if m.locked { + return nil + } + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { + return fmt.Errorf("encode vault for lock: %w", err) + } + + m.encoded = encoded.Bytes() + m.model = vault.Model{} + m.locked = true + return nil +} + +func (m *Manager) Unlock(key vault.MasterKey) error { + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(m.encoded), key) + if err != nil { + return fmt.Errorf("unlock vault: %w", err) + } + + m.model = model + m.config = config + m.key = key + m.locked = false + return nil +} + +func (m *Manager) saveToPath(path string) error { + var encoded bytes.Buffer + if err := vault.SaveKDBXWithConfigAndKey(&encoded, m.model, m.key, m.config); err != nil { + return fmt.Errorf("encode vault: %w", err) + } + + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + + m.encoded = encoded.Bytes() + return nil +} diff --git a/session/session_test.go b/session/session_test.go new file mode 100644 index 0000000..1241b18 --- /dev/null +++ b/session/session_test.go @@ -0,0 +1,477 @@ +package session + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" + "github.com/tobischo/gokeepasslib/v3" + w "github.com/tobischo/gokeepasslib/v3/wrappers" +) + +func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var sess Manager + if err := sess.Create(model, key); err != nil { + t.Fatalf("Create() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "keepassgo.kdbx") + if err := sess.SaveAs(path); err != nil { + t.Fatalf("SaveAs() error = %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("Stat(saved path) error = %v", err) + } + + if err := sess.Lock(); err != nil { + t.Fatalf("Lock() error = %v", err) + } + + if _, err := sess.Current(); !errors.Is(err, ErrLocked) { + t.Fatalf("Current() error = %v, want ErrLocked", err) + } + + if err := sess.Unlock(key); err != nil { + t.Fatalf("Unlock() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() after Unlock() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { + t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got) + } +} + +func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + path := filepath.Join(t.TempDir(), "existing.kdbx") + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create(existing path) error = %v", err) + } + if err := vault.SaveKDBXWithKey(file, model, key); err != nil { + file.Close() + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close(existing path) error = %v", err) + } + + var sess Manager + if err := sess.Open(path, key); err != nil { + t.Fatalf("Open() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(got) != 1 || got[0].Password != "token-2" { + t.Fatalf("Current() entries = %#v, want Home Assistant entry", got) + } +} + +func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + path := filepath.Join(t.TempDir(), "editable.kdbx") + + var sess Manager + if err := sess.Create(model, key); err != nil { + t.Fatalf("Create() error = %v", err) + } + if err := sess.SaveAs(path); err != nil { + t.Fatalf("SaveAs() error = %v", err) + } + + updated := model + updated.UpsertEntry(vault.Entry{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }) + sess.Replace(updated) + + if err := sess.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + reopened, err := os.Open(path) + if err != nil { + t.Fatalf("Open(saved path) error = %v", err) + } + defer reopened.Close() + + loaded, err := vault.LoadKDBXWithKey(reopened, key) + if err != nil { + t.Fatalf("LoadKDBXWithKey() error = %v", err) + } + + got := loaded.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 || got[0].Password != "token-2" { + t.Fatalf("loaded entries = %#v, want updated password token-2", got) + } +} + +func TestSaveWithoutPathFails(t *testing.T) { + t.Parallel() + + var sess Manager + if err := sess.Create(vault.Model{}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil { + t.Fatalf("Create() error = %v", err) + } + + err := sess.Save() + if !errors.Is(err, ErrNoPath) { + t.Fatalf("Save() error = %v, want ErrNoPath", err) + } +} + +func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/vaults/main.kdbx" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var sess Manager + if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("OpenRemote() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 || got[0].Password != "token-1" { + t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got) + } +} + +func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Surveillance Console", + Username: "codex", + Password: "token-1", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + var ( + savedETag string + savedBytes []byte + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + savedETag = r.Header.Get("If-Match") + var err error + savedBytes, err = io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var sess Manager + if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("OpenRemote() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + current.UpsertEntry(vault.Entry{ + ID: "entry-1", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }) + sess.Replace(current) + + if err := sess.SaveRemote(); err != nil { + t.Fatalf("SaveRemote() error = %v", err) + } + + if savedETag != "\"v1\"" { + t.Fatalf("If-Match header = %q, want %q", savedETag, "\"v1\"") + } + + loaded, err := vault.LoadKDBXWithKey(bytes.NewReader(savedBytes), key) + if err != nil { + t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err) + } + + got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(got) != 1 || got[0].Password != "token-2" { + t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) + } +} + +func TestSaveUsesRemoteTargetWhenVaultWasOpenedFromWebDAV(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var putCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + w.Header().Set("ETag", "\"v2\"") + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var sess Manager + if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("OpenRemote() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + current.UpsertEntry(vault.Entry{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }) + sess.Replace(current) + + if err := sess.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + if putCount != 1 { + t.Fatalf("remote PUT count = %d, want 1", putCount) + } +} + +func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + db.Credentials = gokeepasslib.NewPasswordCredentials(key.Password) + db.Content.Root.Groups = []gokeepasslib.Group{ + { + Name: "Root", + Entries: []gokeepasslib.Entry{ + { + UUID: gokeepasslib.NewUUID(), + Values: []gokeepasslib.ValueData{ + {Key: "Title", Value: gokeepasslib.V{Content: "Vault Console"}}, + {Key: "UserName", Value: gokeepasslib.V{Content: "dannyocean"}}, + {Key: "Password", Value: gokeepasslib.V{Content: "token-1", Protected: w.NewBoolWrapper(true)}}, + {Key: "URL", Value: gokeepasslib.V{Content: "https://vault.crew.example.invalid"}}, + }, + }, + }, + }, + } + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "kdbx4.kdbx") + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create(path) error = %v", err) + } + if err := gokeepasslib.NewEncoder(file).Encode(db); err != nil { + file.Close() + t.Fatalf("Encode() error = %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close(path) error = %v", err) + } + + var sess Manager + if err := sess.Open(path, key); err != nil { + t.Fatalf("Open() error = %v", err) + } + + current, err := sess.Current() + if err != nil { + t.Fatalf("Current() error = %v", err) + } + current.UpsertEntry(vault.Entry{ + ID: current.Entries[0].ID, + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: current.Entries[0].Path, + }) + sess.Replace(current) + + if err := sess.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + saved, err := os.Open(path) + if err != nil { + t.Fatalf("Open(saved path) error = %v", err) + } + defer saved.Close() + + reloaded := gokeepasslib.NewDatabase() + reloaded.Credentials = gokeepasslib.NewPasswordCredentials(key.Password) + if err := gokeepasslib.NewDecoder(saved).Decode(reloaded); err != nil { + t.Fatalf("Decode(saved path) error = %v", err) + } + + if !reloaded.Header.IsKdbx4() { + t.Fatal("saved header is not KDBX4, want preserved KDBX4 format") + } + if !bytes.Equal(reloaded.Header.FileHeaders.CipherID, db.Header.FileHeaders.CipherID) { + t.Fatalf("saved cipher = %x, want %x", reloaded.Header.FileHeaders.CipherID, db.Header.FileHeaders.CipherID) + } + if !bytes.Equal(reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID) { + t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID) + } +} diff --git a/ui_editor.go b/ui_editor.go new file mode 100644 index 0000000..b855402 --- /dev/null +++ b/ui_editor.go @@ -0,0 +1,337 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + + "git.julianfamily.org/keepassgo/appstate" + "git.julianfamily.org/keepassgo/clipboard" + "git.julianfamily.org/keepassgo/passwords" + "git.julianfamily.org/keepassgo/vault" +) + +func (u *ui) loadSelectedEntryIntoEditor() { + item, ok := u.selectedEntry() + if !ok { + u.entryID.SetText("") + u.entryTitle.SetText("") + u.entryUsername.SetText("") + u.entryPassword.SetText("") + u.entryURL.SetText("") + u.entryNotes.SetText("") + u.entryTags.SetText("") + u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.entryFields.SetText("") + u.attachmentName.SetText("") + u.attachmentPath.SetText("") + u.exportAttachmentPath.SetText("") + return + } + + u.entryID.SetText(item.ID) + u.entryTitle.SetText(item.Title) + u.entryUsername.SetText(item.Username) + u.entryPassword.SetText(item.Password) + u.entryURL.SetText(item.URL) + u.entryNotes.SetText(item.Notes) + u.entryTags.SetText(strings.Join(item.Tags, ", ")) + u.entryPath.SetText(strings.Join(item.Path, " / ")) + u.entryFields.SetText(marshalFields(item.Fields)) + u.attachmentName.SetText("") + u.attachmentPath.SetText("") + u.exportAttachmentPath.SetText("") +} + +func (u *ui) saveEntryAction() error { + entry, err := u.editorEntry() + if err != nil { + return err + } + if err := u.state.UpsertEntry(entry); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) duplicateSelectedEntryAction() error { + baseID := strings.TrimSpace(u.state.SelectedEntryID) + if baseID == "" { + return fmt.Errorf("no entry selected") + } + + duplicateID := baseID + "-copy" + if _, err := u.state.DuplicateSelectedEntry(duplicateID); err != nil { + return err + } + + u.loadSelectedEntryIntoEditor() + u.filter() + return nil +} + +func (u *ui) deleteSelectedEntryAction() error { + if err := u.state.DeleteSelectedEntry(); err != nil { + return err + } + + u.loadSelectedEntryIntoEditor() + u.filter() + return nil +} + +func (u *ui) restoreSelectedRecycleEntryAction() error { + id := strings.TrimSpace(u.state.SelectedEntryID) + if id == "" { + return fmt.Errorf("no recycle-bin entry selected") + } + if err := u.state.RestoreEntry(id); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) saveTemplateAction() error { + entry, err := u.editorEntry() + if err != nil { + return err + } + if len(entry.Path) == 0 { + entry.Path = []string{"Templates"} + } + if err := u.state.UpsertTemplate(entry); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) deleteSelectedTemplateAction() error { + id := strings.TrimSpace(u.state.SelectedEntryID) + if id == "" { + return fmt.Errorf("no template selected") + } + if err := u.state.DeleteTemplate(id); err != nil { + return err + } + u.loadSelectedEntryIntoEditor() + u.filter() + return nil +} + +func (u *ui) createGroupAction() error { + return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text())) +} + +func (u *ui) renameGroupAction() error { + return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text())) +} + +func (u *ui) deleteCurrentGroupAction() error { + session, ok := u.state.Session.(appstate.MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + if err := model.DeleteGroup(u.currentPath); err != nil { + return err + } + session.Replace(model) + if len(u.currentPath) > 0 { + u.currentPath = append([]string(nil), u.currentPath[:len(u.currentPath)-1]...) + u.state.CurrentPath = append([]string(nil), u.currentPath...) + } + u.state.Dirty = true + u.filter() + return nil +} + +func (u *ui) instantiateSelectedTemplateAction() error { + templateID := strings.TrimSpace(u.state.SelectedEntryID) + if templateID == "" { + return fmt.Errorf("no template selected") + } + + entry, err := u.editorEntry() + if err != nil { + return err + } + if _, err := u.state.InstantiateTemplate(templateID, entry); err != nil { + return err + } + u.filter() + return nil +} + +func (u *ui) addAttachmentAction() error { + content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text())) + if err != nil { + return fmt.Errorf("read attachment: %w", err) + } + + name := strings.TrimSpace(u.attachmentName.Text()) + if name == "" { + return fmt.Errorf("attachment name is required") + } + + if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil { + return err + } + u.loadSelectedEntryIntoEditor() + u.attachmentName.SetText(name) + u.filter() + return nil +} + +func (u *ui) exportAttachmentAction() error { + item, ok := u.selectedEntry() + if !ok { + return fmt.Errorf("no entry selected") + } + + name := strings.TrimSpace(u.attachmentName.Text()) + content, ok := item.Attachments[name] + if !ok { + return fmt.Errorf("attachment not found") + } + + if err := os.WriteFile(strings.TrimSpace(u.exportAttachmentPath.Text()), content, 0o600); err != nil { + return fmt.Errorf("write attachment export: %w", err) + } + return nil +} + +func (u *ui) removeAttachmentAction() error { + name := strings.TrimSpace(u.attachmentName.Text()) + if name == "" { + return fmt.Errorf("attachment name is required") + } + + if err := u.state.DeleteAttachmentFromSelectedEntry(name); err != nil { + return err + } + u.loadSelectedEntryIntoEditor() + u.filter() + return nil +} + +func (u *ui) restoreSelectedHistoryAction() error { + index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text())) + if err != nil { + return fmt.Errorf("invalid history index: %w", err) + } + if err := u.state.RestoreSelectedEntryVersion(index); err != nil { + return err + } + u.loadSelectedEntryIntoEditor() + u.filter() + return nil +} + +func (u *ui) copySelectedFieldAction(target clipboard.Target) error { + model, err := u.state.Session.Current() + if err != nil { + return err + } + + service := clipboard.Service{} + return service.Copy(model, u.state.SelectedEntryID, target) +} + +func (u *ui) generatePasswordAction() error { + profiles := passwords.DefaultProfiles() + profile, ok := profiles[strings.TrimSpace(u.passwordProfile.Text())] + if !ok { + return fmt.Errorf("unknown password profile") + } + + password, err := passwords.Generate(profile) + if err != nil { + return err + } + u.entryPassword.SetText(password) + return nil +} + +func (u *ui) editorEntry() (vault.Entry, error) { + path := parsePath(u.entryPath.Text()) + fields, err := parseFields(u.entryFields.Text()) + if err != nil { + return vault.Entry{}, err + } + + return vault.Entry{ + ID: strings.TrimSpace(u.entryID.Text()), + Title: strings.TrimSpace(u.entryTitle.Text()), + Username: strings.TrimSpace(u.entryUsername.Text()), + Password: u.entryPassword.Text(), + URL: strings.TrimSpace(u.entryURL.Text()), + Notes: strings.TrimSpace(u.entryNotes.Text()), + Tags: parseTags(u.entryTags.Text()), + Path: path, + Fields: fields, + }, nil +} + +func parsePath(text string) []string { + var out []string + for _, part := range strings.Split(text, "/") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out +} + +func parseTags(text string) []string { + var out []string + for _, part := range strings.Split(text, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + out = append(out, part) + } + return out +} + +func parseFields(text string) (map[string]string, error) { + if strings.TrimSpace(text) == "" { + return nil, nil + } + + fields := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("invalid field line %q", line) + } + fields[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + return fields, nil +} + +func marshalFields(fields map[string]string) string { + if len(fields) == 0 { + return "" + } + + lines := make([]string, 0, len(fields)) + for key, value := range fields { + lines = append(lines, key+"="+value) + } + return strings.Join(lines, "\n") +} diff --git a/ui_forms.go b/ui_forms.go new file mode 100644 index 0000000..fd513a6 --- /dev/null +++ b/ui_forms.go @@ -0,0 +1,168 @@ +package main + +import ( + "strings" + + "git.julianfamily.org/keepassgo/appstate" + "gioui.org/layout" + "gioui.org/unit" + "gioui.org/widget" + "gioui.org/widget/material" +) + +func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditor(u.theme, "Master Password", &u.masterPassword, true)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Key File", &u.keyFilePath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Vault Path", &u.vaultPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Save As Path", &u.saveAsPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Remote Base URL", &u.remoteBaseURL, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Remote Path", &u.remotePath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Remote Username", &u.remoteUsername, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Remote Password", &u.remotePassword, true)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createVault, "New") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openVault, "Open") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveVault, "Save") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveAsVault, "Save As") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.unlockVault, "Unlock") }), + ) + }), + ) +} + +func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionRecycleBin { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group") }), + ) + }), + ) +} + +func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + switch u.state.Section { + case appstate.SectionTemplates: + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveTemplate, "Save Template") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteTemplate, "Delete Template") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.instantiateTemplate, "Instantiate") }), + ) + case appstate.SectionRecycleBin: + return tonedButton(gtx, u.theme, &u.restoreEntry, "Restore Entry") + default: + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveEntry, "Save Entry") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.duplicateEntry, "Duplicate") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteEntry, "Delete") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.generatePassword, "Generate Password") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.restoreHistory, "Restore History") }), + ) + } + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyUser, "Copy User") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyPass, "Copy Password") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.copyURL, "Copy URL") }), + ) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditor(u.theme, "Export Attachment Path", &u.exportAttachmentPath, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.exportAttachment, "Export Attachment") }), + ) + }), + ) +} + +func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + mask := editor.Mask + if sensitive { + editor.Mask = '•' + } + defer func() { editor.Mask = mask }() + ed := material.Editor(th, editor, label) + return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout) + }) + }), + ) + } +} diff --git a/ui_shortcuts.go b/ui_shortcuts.go new file mode 100644 index 0000000..b3ee142 --- /dev/null +++ b/ui_shortcuts.go @@ -0,0 +1,85 @@ +package main + +import ( + "strings" + + "gioui.org/io/event" + "gioui.org/io/key" + "gioui.org/layout" + + "git.julianfamily.org/keepassgo/clipboard" +) + +const ( + shortcutSearch = "search" + shortcutSave = "save" + shortcutLock = "lock" + shortcutNewEntry = "new-entry" + shortcutCopyUser = "copy-user" + shortcutCopyPassword = "copy-password" + shortcutCopyURL = "copy-url" +) + +func (u *ui) processShortcuts(gtx layout.Context) { + event.Op(gtx.Ops, u) + for { + ev, ok := gtx.Event( + key.Filter{Focus: u, Name: "F", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "S", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "L", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "N", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "U", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "P", Required: key.ModShortcut}, + key.Filter{Focus: u, Name: "O", Required: key.ModShortcut}, + ) + if !ok { + break + } + + ke, ok := ev.(key.Event) + if !ok || ke.State != key.Press { + continue + } + + switch ke.Name { + case "F": + _ = u.performShortcut(shortcutSearch) + case "S": + _ = u.performShortcut(shortcutSave) + case "L": + _ = u.performShortcut(shortcutLock) + case "N": + _ = u.performShortcut(shortcutNewEntry) + case "U": + _ = u.performShortcut(shortcutCopyUser) + case "P": + _ = u.performShortcut(shortcutCopyPassword) + case "O": + _ = u.performShortcut(shortcutCopyURL) + } + } +} + +func (u *ui) performShortcut(name string) error { + switch name { + case shortcutSearch: + return nil + case shortcutSave: + return u.saveAction() + case shortcutLock: + return u.lockAction() + case shortcutNewEntry: + u.state.SelectedEntryID = "" + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + return nil + case shortcutCopyUser: + return u.copySelectedFieldAction(clipboard.TargetUsername) + case shortcutCopyPassword: + return u.copySelectedFieldAction(clipboard.TargetPassword) + case shortcutCopyURL: + return u.copySelectedFieldAction(clipboard.TargetURL) + default: + return nil + } +} diff --git a/vault/history_test.go b/vault/history_test.go new file mode 100644 index 0000000..a93db68 --- /dev/null +++ b/vault/history_test.go @@ -0,0 +1,160 @@ +package vault + +import "testing" + +func TestUpsertEntryPreservesPreviousVersionInHistory(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "old-token", + URL: "https://vault.crew.example.invalid", + Notes: "Original note", + Path: []string{"Root", "Internet"}, + }, + }, + } + + model.UpsertEntry(Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "new-token", + URL: "https://vault.crew.example.invalid", + Notes: "Updated note", + Path: []string{"Root", "Internet"}, + }) + + got := model.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + + if got[0].Password != "new-token" { + t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "new-token") + } + + if len(got[0].History) != 1 { + t.Fatalf("len(Entry.History) = %d, want 1", len(got[0].History)) + } + + if got[0].History[0].Password != "old-token" || got[0].History[0].Notes != "Original note" { + t.Fatalf("Entry.History[0] = %#v, want prior entry version", got[0].History[0]) + } +} + +func TestDeleteEntryMovesItToRecycleBin(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "surveillance-console", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + if err := model.DeleteEntry("surveillance-console"); err != nil { + t.Fatalf("DeleteEntry() error = %v", err) + } + + if got := model.EntriesInPath([]string{"Root", "Home Assistant"}); len(got) != 0 { + t.Fatalf("EntriesInPath() = %#v, want empty after delete", got) + } + + if len(model.RecycleBin) != 1 { + t.Fatalf("len(RecycleBin) = %d, want 1", len(model.RecycleBin)) + } + + if model.RecycleBin[0].Title != "Surveillance Console" { + t.Fatalf("RecycleBin[0].Title = %q, want %q", model.RecycleBin[0].Title, "Surveillance Console") + } +} + +func TestRestoreEntryMovesItBackFromRecycleBin(t *testing.T) { + t.Parallel() + + model := Model{ + RecycleBin: []Entry{ + { + ID: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-3", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + if err := model.RestoreEntry("bellagio"); err != nil { + t.Fatalf("RestoreEntry() error = %v", err) + } + + got := model.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + + if got[0].Title != "Bellagio" { + t.Fatalf("EntriesInPath()[0].Title = %q, want %q", got[0].Title, "Bellagio") + } + + if len(model.RecycleBin) != 0 { + t.Fatalf("len(RecycleBin) = %d, want 0", len(model.RecycleBin)) + } +} + +func TestRestoreEntryVersionPromotesHistoricalVersionAndRetainsCurrentInHistory(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "new-token", + Notes: "Current note", + Path: []string{"Root", "Internet"}, + History: []Entry{ + { + ID: "vault-console-history-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "old-token", + Notes: "Previous note", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + } + + if err := model.RestoreEntryVersion("vault-console", 0); err != nil { + t.Fatalf("RestoreEntryVersion() error = %v", err) + } + + got := model.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + if got[0].Password != "old-token" || got[0].Notes != "Previous note" { + t.Fatalf("restored entry = %#v, want old-token/Previous note current version", got[0]) + } + if len(got[0].History) != 1 { + t.Fatalf("len(History) = %d, want 1", len(got[0].History)) + } + if got[0].History[0].Password != "new-token" || got[0].History[0].Notes != "Current note" { + t.Fatalf("History[0] = %#v, want prior current version retained", got[0].History[0]) + } +} diff --git a/vault/kdbx.go b/vault/kdbx.go new file mode 100644 index 0000000..93f4ab9 --- /dev/null +++ b/vault/kdbx.go @@ -0,0 +1,550 @@ +package vault + +import ( + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" + "maps" + "slices" + "strings" + "time" + + "github.com/tobischo/gokeepasslib/v3" + w "github.com/tobischo/gokeepasslib/v3/wrappers" +) + +type KDBXConfig struct { + Header *gokeepasslib.DBHeader + InnerHeader *gokeepasslib.InnerHeader +} + +var ErrInvalidMasterKey = errors.New("invalid master key") + +const ( + templatesRoot = "Templates" + recycleBinRoot = "Recycle Bin" + keepassGOIDField = "KeePassGO-ID" +) + +func LoadKDBX(r io.Reader, password string) (Model, error) { + return LoadKDBXWithKey(r, MasterKey{Password: password}) +} + +func SaveKDBX(wr io.Writer, model Model, password string) error { + return SaveKDBXWithKey(wr, model, MasterKey{Password: password}) +} + +func SaveKDBXWithKey(wr io.Writer, model Model, key MasterKey) error { + return SaveKDBXWithConfigAndKey(wr, model, key, nil) +} + +func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *KDBXConfig) error { + credentials, err := newCredentials(key) + if err != nil { + return err + } + + header := gokeepasslib.NewHeader() + if config != nil && config.Header != nil { + header = cloneHeader(config.Header) + } + content := &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{}, + } + if header.IsKdbx4() { + if config != nil && config.InnerHeader != nil { + content.InnerHeader = cloneInnerHeader(config.InnerHeader) + } else { + content.InnerHeader = &gokeepasslib.InnerHeader{ + InnerRandomStreamID: gokeepasslib.ChaChaStreamID, + InnerRandomStreamKey: randomBytes(64), + } + } + } + + db := &gokeepasslib.Database{ + Header: header, + Credentials: credentials, + Content: content, + Hashes: gokeepasslib.NewHashes(header), + } + db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model)) + db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin) + + if err := db.LockProtectedEntries(); err != nil { + return fmt.Errorf("lock protected entries: %w", err) + } + + if err := gokeepasslib.NewEncoder(wr).Encode(db); err != nil { + return fmt.Errorf("encode kdbx: %w", err) + } + + return nil +} + +func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) { + path = append(clonePath(path), group.Name) + + for _, entry := range group.Entries { + appendModelEntry(model, Entry{ + ID: extractEntryID(entry), + Title: entry.GetTitle(), + Username: entry.GetContent("UserName"), + Password: entry.GetPassword(), + URL: entry.GetContent("URL"), + Notes: entry.GetContent("Notes"), + Tags: splitTags(entry.Tags), + Fields: extractCustomFields(entry), + Attachments: extractAttachments(db, entry), + History: extractHistory(db, entry, path), + Path: clonePath(path), + }) + } + + for _, child := range group.Groups { + appendGroupEntries(model, db, child, path) + } +} + +func appendModelEntry(model *Model, entry Entry) { + if len(entry.Path) == 0 { + model.Entries = append(model.Entries, entry) + return + } + + switch entry.Path[0] { + case templatesRoot: + model.Templates = append(model.Templates, entry) + return + case recycleBinRoot: + entry.Path = slices.Clone(entry.Path[1:]) + model.RecycleBin = append(model.RecycleBin, entry) + return + } + + model.Entries = append(model.Entries, entry) +} + +func entriesForPersistence(model Model) []Entry { + entries := append(slices.Clone(model.Entries), model.Templates...) + for _, entry := range model.RecycleBin { + recycleEntry := cloneEntry(entry) + recycleEntry.Path = append([]string{recycleBinRoot}, recycleEntry.Path...) + entries = append(entries, recycleEntry) + } + return entries +} + +func marshalUUID(id gokeepasslib.UUID) string { + text, err := id.MarshalText() + if err != nil { + return "" + } + return string(text) +} + +func clonePath(path []string) []string { + if len(path) == 0 { + return nil + } + out := make([]string, len(path)) + copy(out, path) + return out +} + +func splitTags(tags string) []string { + if strings.TrimSpace(tags) == "" { + return nil + } + + fields := strings.Split(tags, ";") + var out []string + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + out = append(out, field) + } + return out +} + +func extractCustomFields(entry gokeepasslib.Entry) map[string]string { + fields := map[string]string{} + for _, value := range entry.Values { + switch value.Key { + case "Title", "UserName", "Password", "URL", "Notes", keepassGOIDField: + continue + default: + fields[value.Key] = value.Value.Content + } + } + + if len(fields) == 0 { + return nil + } + + return fields +} + +func extractEntryID(entry gokeepasslib.Entry) string { + if id := entry.GetContent(keepassGOIDField); id != "" { + return id + } + + return marshalUUID(entry.UUID) +} + +func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path []string) []Entry { + if len(entry.Histories) == 0 { + return nil + } + + var history []Entry + for _, item := range entry.Histories { + for _, historical := range item.Entries { + history = append(history, Entry{ + ID: marshalUUID(historical.UUID), + Title: historical.GetTitle(), + Username: historical.GetContent("UserName"), + Password: historical.GetPassword(), + URL: historical.GetContent("URL"), + Notes: historical.GetContent("Notes"), + Tags: splitTags(historical.Tags), + Fields: extractCustomFields(historical), + Attachments: extractAttachments(db, historical), + Path: clonePath(path), + }) + } + } + + return history +} + +type groupNode struct { + name string + children map[string]*groupNode + entries []Entry +} + +type MasterKey struct { + Password string + KeyFileData []byte +} + +func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group { + root := &groupNode{children: map[string]*groupNode{}} + for _, entry := range entries { + node := root + for _, segment := range entry.Path { + if node.children[segment] == nil { + node.children[segment] = &groupNode{ + name: segment, + children: map[string]*groupNode{}, + } + } + node = node.children[segment] + } + node.entries = append(node.entries, entry) + } + + groups := marshalGroups(db, root) + if len(groups) > 0 { + return groups + } + + group := gokeepasslib.NewGroup() + group.Name = "Root" + return []gokeepasslib.Group{group} +} + +func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) { + model, _, err := LoadKDBXWithConfig(r, key) + return model, err +} + +func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error) { + credentials, err := newCredentials(key) + if err != nil { + return Model{}, nil, err + } + + db := gokeepasslib.NewDatabase() + db.Credentials = credentials + + if err := gokeepasslib.NewDecoder(r).Decode(db); err != nil { + if isInvalidCredentialError(err) { + return Model{}, nil, ErrInvalidMasterKey + } + return Model{}, nil, fmt.Errorf("decode kdbx: %w", err) + } + + if err := db.UnlockProtectedEntries(); err != nil { + return Model{}, nil, fmt.Errorf("unlock protected entries: %w", err) + } + + var model Model + for _, group := range db.Content.Root.Groups { + appendGroupEntries(&model, db, group, nil) + } + + return model, &KDBXConfig{ + Header: cloneHeader(db.Header), + InnerHeader: cloneInnerHeader(db.Content.InnerHeader), + }, nil +} + +func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) { + switch { + case key.Password != "" && len(key.KeyFileData) > 0: + credentials, err := gokeepasslib.NewPasswordAndKeyDataCredentials(key.Password, key.KeyFileData) + if err != nil { + return nil, fmt.Errorf("build password+key credentials: %w", err) + } + return credentials, nil + case len(key.KeyFileData) > 0: + credentials, err := gokeepasslib.NewKeyDataCredentials(key.KeyFileData) + if err != nil { + return nil, fmt.Errorf("build key credentials: %w", err) + } + return credentials, nil + default: + return gokeepasslib.NewPasswordCredentials(key.Password), nil + } +} + +func cloneHeader(header *gokeepasslib.DBHeader) *gokeepasslib.DBHeader { + if header == nil { + return nil + } + + out := *header + out.RawData = nil + if header.Signature != nil { + signature := *header.Signature + out.Signature = &signature + } + if header.FileHeaders != nil { + fileHeaders := *header.FileHeaders + fileHeaders.Comment = slices.Clone(header.FileHeaders.Comment) + fileHeaders.CipherID = slices.Clone(header.FileHeaders.CipherID) + fileHeaders.MasterSeed = slices.Clone(header.FileHeaders.MasterSeed) + fileHeaders.TransformSeed = slices.Clone(header.FileHeaders.TransformSeed) + fileHeaders.EncryptionIV = slices.Clone(header.FileHeaders.EncryptionIV) + fileHeaders.ProtectedStreamKey = slices.Clone(header.FileHeaders.ProtectedStreamKey) + fileHeaders.StreamStartBytes = slices.Clone(header.FileHeaders.StreamStartBytes) + if header.FileHeaders.KdfParameters != nil { + kdf := *header.FileHeaders.KdfParameters + kdf.UUID = slices.Clone(header.FileHeaders.KdfParameters.UUID) + kdf.SecretKey = slices.Clone(header.FileHeaders.KdfParameters.SecretKey) + kdf.AssocData = slices.Clone(header.FileHeaders.KdfParameters.AssocData) + if header.FileHeaders.KdfParameters.RawData != nil { + kdf.RawData = cloneVariantDictionary(header.FileHeaders.KdfParameters.RawData) + } + fileHeaders.KdfParameters = &kdf + } + if header.FileHeaders.PublicCustomData != nil { + fileHeaders.PublicCustomData = cloneVariantDictionary(header.FileHeaders.PublicCustomData) + } + out.FileHeaders = &fileHeaders + } + + return &out +} + +func cloneVariantDictionary(dict *gokeepasslib.VariantDictionary) *gokeepasslib.VariantDictionary { + if dict == nil { + return nil + } + + out := &gokeepasslib.VariantDictionary{Version: dict.Version} + out.Items = make([]*gokeepasslib.VariantDictionaryItem, 0, len(dict.Items)) + for _, item := range dict.Items { + cloned := *item + cloned.Name = slices.Clone(item.Name) + cloned.Value = slices.Clone(item.Value) + out.Items = append(out.Items, &cloned) + } + return out +} + +func cloneInnerHeader(header *gokeepasslib.InnerHeader) *gokeepasslib.InnerHeader { + if header == nil { + return nil + } + + out := &gokeepasslib.InnerHeader{ + InnerRandomStreamID: header.InnerRandomStreamID, + InnerRandomStreamKey: slices.Clone(header.InnerRandomStreamKey), + } + for _, binary := range header.Binaries { + out.Binaries = append(out.Binaries, gokeepasslib.Binary{ + ID: binary.ID, + Compressed: binary.Compressed, + MemoryProtection: binary.MemoryProtection, + Content: slices.Clone(binary.Content), + }) + } + return out +} + +func randomBytes(length int) []byte { + buf := make([]byte, length) + _, _ = io.ReadFull(rand.Reader, buf) + return buf +} + +func isInvalidCredentialError(err error) bool { + if errors.Is(err, gokeepasslib.ErrInvalidDatabaseOrCredentials) { + return true + } + + return strings.Contains(err.Error(), "Wrong password?") +} + +func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group { + names := slices.Collect(maps.Keys(node.children)) + slices.Sort(names) + + var groups []gokeepasslib.Group + for _, name := range names { + child := node.children[name] + group := gokeepasslib.NewGroup() + group.Name = child.name + group.Entries = marshalEntries(db, child.entries) + group.Groups = marshalGroups(db, child) + groups = append(groups, group) + } + + return groups +} + +func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry { + slices.SortFunc(entries, func(a, b Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + + var out []gokeepasslib.Entry + for _, entry := range entries { + out = append(out, marshalEntry(db, entry)) + } + + return out +} + +func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry { + item := gokeepasslib.NewEntry() + item.UUID = uuidForEntryID(entry.ID) + item.Tags = strings.Join(entry.Tags, "; ") + item.Values = append(item.Values, + value("Title", entry.Title), + value("UserName", entry.Username), + protectedValue("Password", entry.Password), + value("URL", entry.URL), + value("Notes", entry.Notes), + value(keepassGOIDField, entry.ID), + ) + + keys := slices.Collect(maps.Keys(entry.Fields)) + slices.Sort(keys) + for _, key := range keys { + item.Values = append(item.Values, value(key, entry.Fields[key])) + } + + attachmentNames := slices.Collect(maps.Keys(entry.Attachments)) + slices.Sort(attachmentNames) + for _, name := range attachmentNames { + binary := db.AddBinary(entry.Attachments[name]) + item.Binaries = append(item.Binaries, binary.CreateReference(name)) + } + + for _, historical := range entry.History { + item.Histories = append(item.Histories, gokeepasslib.History{ + Entries: []gokeepasslib.Entry{marshalEntry(db, historical)}, + }) + } + + return item +} + +func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData { + if len(entries) == 0 { + return nil + } + + deletionTime := w.Now() + out := make([]gokeepasslib.DeletedObjectData, 0, len(entries)) + for _, entry := range entries { + out = append(out, gokeepasslib.DeletedObjectData{ + UUID: uuidForEntryID(entry.ID), + DeletionTime: &deletionTime, + }) + } + + return out +} + +func uuidForEntryID(id string) gokeepasslib.UUID { + if id != "" { + var uuid gokeepasslib.UUID + if err := uuid.UnmarshalText([]byte(id)); err == nil { + return uuid + } + } + + sum := sha256.Sum256([]byte(id)) + var uuid gokeepasslib.UUID + copy(uuid[:], sum[:len(uuid)]) + if id == "" { + copy(uuid[:], time.Now().UTC().AppendFormat(nil, time.RFC3339Nano)) + } + return uuid +} + +func value(key, content string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: content}} +} + +func protectedValue(key, content string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{ + Key: key, + Value: gokeepasslib.V{Content: content, Protected: w.NewBoolWrapper(true)}, + } +} + +func extractAttachments(db *gokeepasslib.Database, entry gokeepasslib.Entry) map[string][]byte { + if len(entry.Binaries) == 0 { + return nil + } + + attachments := map[string][]byte{} + for _, ref := range entry.Binaries { + binary := db.FindBinary(ref.Value.ID) + if binary == nil { + continue + } + + content, err := binary.GetContentBytes() + if err != nil { + continue + } + + attachments[ref.Name] = slices.Clone(content) + } + + if len(attachments) == 0 { + return nil + } + + return attachments +} diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go new file mode 100644 index 0000000..a147b64 --- /dev/null +++ b/vault/kdbx_test.go @@ -0,0 +1,641 @@ +package vault + +import ( + "bytes" + "errors" + "testing" + + "github.com/tobischo/gokeepasslib/v3" + w "github.com/tobischo/gokeepasslib/v3/wrappers" +) + +func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) { + t.Parallel() + + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"), + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{ + mustGroup("Root", + mustGroup("Internet", + mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"), + mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"), + ), + mustGroup("Home Assistant", + mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"), + ), + ), + }, + }, + }, + } + + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries failed: %v", err) + } + + var encoded bytes.Buffer + if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil { + t.Fatalf("Encode failed: %v", err) + } + + model, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX failed: %v", err) + } + + got := model.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) + } + + if got[0].Title != "Bellagio" || got[0].Username != "rustyryan" || got[0].URL != "https://bellagio.example.invalid" { + t.Fatalf("unexpected first entry: %#v", got[0]) + } + + if got[1].Title != "Vault Console" || got[1].Username != "dannyocean" || got[1].URL != "https://vault.crew.example.invalid" { + t.Fatalf("unexpected second entry: %#v", got[1]) + } + + groups := model.ChildGroups([]string{"Root"}) + if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" { + t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups) + } +} + +func TestLoadKDBXPreservesEntryDetails(t *testing.T) { + t.Parallel() + + entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2") + entry.Tags = "automation; home" + entry.Values = append(entry.Values, + mkValue("Notes", "Long-lived token used by Codex for home automation tasks."), + mkValue("X-Role", "automation"), + ) + + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"), + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{ + mustGroup("Root", mustGroup("Home Assistant", entry)), + }, + }, + }, + } + + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries failed: %v", err) + } + + var encoded bytes.Buffer + if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil { + t.Fatalf("Encode failed: %v", err) + } + + model, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX failed: %v", err) + } + + got := model.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + + if got[0].Password != "token-2" { + t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2") + } + + if got[0].Notes != "Long-lived token used by Codex for home automation tasks." { + t.Fatalf("Entry.Notes = %q, want %q", got[0].Notes, "Long-lived token used by Codex for home automation tasks.") + } + + if len(got[0].Tags) != 2 || got[0].Tags[0] != "automation" || got[0].Tags[1] != "home" { + t.Fatalf("Entry.Tags = %v, want [automation home]", got[0].Tags) + } + + if got[0].Fields["X-Role"] != "automation" { + t.Fatalf("Entry.Fields[\"X-Role\"] = %q, want %q", got[0].Fields["X-Role"], "automation") + } +} + +func TestSaveKDBXRoundTripsModel(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Notes: "Personal git server token entry used for automation and CLI auth.", + Tags: []string{"git", "infra"}, + Fields: map[string]string{ + "X-Role": "automation", + }, + Path: []string{"Root", "Internet"}, + }, + { + ID: "entry-2", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Notes: "Long-lived token used by Codex for home automation tasks.", + Tags: []string{"automation", "home"}, + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + got := loaded.Search("vault") + if len(got) != 1 { + t.Fatalf("len(Search(\"git\")) = %d, want 1", len(got)) + } + + if got[0].Entry.Notes != "Personal git server token entry used for automation and CLI auth." { + t.Fatalf("Search(\"git\") notes = %q, want %q", got[0].Entry.Notes, "Personal git server token entry used for automation and CLI auth.") + } + + if got[0].Entry.Fields["X-Role"] != "automation" { + t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation") + } + + homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(homeAssistant) != 1 { + t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant)) + } + + if homeAssistant[0].Password != "token-2" { + t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2") + } +} + +func TestSaveKDBXRoundTripsTemplates(t *testing.T) { + t.Parallel() + + model := Model{ + Templates: []Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Tags: []string{"template", "web"}, + Fields: map[string]string{ + "Environment": "prod", + }, + Path: []string{"Templates"}, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + if len(loaded.Templates) != 1 { + t.Fatalf("len(Templates) = %d, want 1", len(loaded.Templates)) + } + + if loaded.Templates[0].Title != "Website Login" { + t.Fatalf("Templates[0].Title = %q, want %q", loaded.Templates[0].Title, "Website Login") + } + + if loaded.Templates[0].Fields["Environment"] != "prod" { + t.Fatalf("Templates[0].Fields[Environment] = %q, want %q", loaded.Templates[0].Fields["Environment"], "prod") + } + + if len(loaded.Entries) != 0 { + t.Fatalf("len(Entries) = %d, want 0", len(loaded.Entries)) + } +} + +func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "new-token", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + History: []Entry{ + { + ID: "entry-1-old", + Title: "Vault Console", + Username: "dannyocean", + Password: "old-token", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Notes: "Original version", + }, + }, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + got := loaded.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + + if len(got[0].History) != 1 { + t.Fatalf("len(History) = %d, want 1", len(got[0].History)) + } + + if got[0].History[0].Password != "old-token" || got[0].History[0].Notes != "Original version" { + t.Fatalf("History[0] = %#v, want preserved prior version", got[0].History[0]) + } +} + +func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) { + t.Parallel() + + model := Model{ + RecycleBin: []Entry{ + { + ID: "entry-1", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + if len(loaded.RecycleBin) != 1 { + t.Fatalf("len(RecycleBin) = %d, want 1", len(loaded.RecycleBin)) + } + + if loaded.RecycleBin[0].Title != "Surveillance Console" { + t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console") + } + + if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" { + t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path) + } + + if len(loaded.Entries) != 0 { + t.Fatalf("len(Entries) = %d, want 0", len(loaded.Entries)) + } +} + +func TestLoadKDBXWithKeyFileCredentials(t *testing.T) { + t.Parallel() + + keyData := []byte(` + + + 1.0 + + + PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI= + + +`) + + credentials, err := newCredentials(MasterKey{KeyFileData: keyData}) + if err != nil { + t.Fatalf("newCredentials() error = %v", err) + } + + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: credentials, + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{ + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + }, + }, + }, + } + + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries failed: %v", err) + } + + var encoded bytes.Buffer + if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil { + t.Fatalf("Encode failed: %v", err) + } + + model, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{KeyFileData: keyData}) + if err != nil { + t.Fatalf("LoadKDBXWithKey() error = %v", err) + } + + got := model.Search("vault") + if len(got) != 1 || got[0].Entry.Password != "token-1" { + t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got) + } +} + +func TestLoadKDBXWithCompositeCredentials(t *testing.T) { + t.Parallel() + + keyData := []byte(` + + + 1.0 + + + PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI= + + +`) + + credentials, err := newCredentials(MasterKey{ + Password: "correct horse battery staple", + KeyFileData: keyData, + }) + if err != nil { + t.Fatalf("newCredentials() error = %v", err) + } + + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: credentials, + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{ + mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))), + }, + }, + }, + } + + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries failed: %v", err) + } + + var encoded bytes.Buffer + if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil { + t.Fatalf("Encode failed: %v", err) + } + + model, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{ + Password: "correct horse battery staple", + KeyFileData: keyData, + }) + if err != nil { + t.Fatalf("LoadKDBXWithKey() error = %v", err) + } + + got := model.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(got) != 1 || got[0].Password != "token-2" { + t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got) + } +} + +func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) { + t.Parallel() + + db := &gokeepasslib.Database{ + Header: gokeepasslib.NewHeader(), + Credentials: gokeepasslib.NewPasswordCredentials("correct horse battery staple"), + Content: &gokeepasslib.DBContent{ + Meta: gokeepasslib.NewMetaData(), + Root: &gokeepasslib.RootData{ + Groups: []gokeepasslib.Group{ + mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))), + }, + }, + }, + } + + if err := db.LockProtectedEntries(); err != nil { + t.Fatalf("LockProtectedEntries failed: %v", err) + } + + var encoded bytes.Buffer + if err := gokeepasslib.NewEncoder(&encoded).Encode(db); err != nil { + t.Fatalf("Encode failed: %v", err) + } + + _, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "definitely wrong password") + if !errors.Is(err, ErrInvalidMasterKey) { + t.Fatalf("LoadKDBX() error = %v, want %v", err, ErrInvalidMasterKey) + } +} + +func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) { + t.Parallel() + + keyData := []byte(` + + + 1.0 + + + PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI= + + +`) + + model := Model{ + Entries: []Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBXWithKey(&encoded, model, MasterKey{KeyFileData: keyData}); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + loaded, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), MasterKey{KeyFileData: keyData}) + if err != nil { + t.Fatalf("LoadKDBXWithKey() error = %v", err) + } + + got := loaded.Search("vault") + if len(got) != 1 || got[0].Entry.Password != "token-1" { + t.Fatalf("round-trip with key file = %#v, want vault entry with password", got) + } +} + +func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) { + t.Parallel() + + keyData := []byte(` + + + 1.0 + + + PbLBYmgEXFhLWf2gxoBMARXgDZGE7f34tr+anCw52LI= + + +`) + + model := Model{ + Entries: []Entry{ + { + ID: "surveillance-console", + Title: "Surveillance Console", + Username: "codex", + Password: "token-2", + URL: "https://surveillance.crew.example.invalid", + Path: []string{"Root", "Home Assistant"}, + }, + }, + } + + key := MasterKey{ + Password: "correct horse battery staple", + KeyFileData: keyData, + } + + var encoded bytes.Buffer + if err := SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + loaded, err := LoadKDBXWithKey(bytes.NewReader(encoded.Bytes()), key) + if err != nil { + t.Fatalf("LoadKDBXWithKey() error = %v", err) + } + + got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) + if len(got) != 1 || got[0].Password != "token-2" { + t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got) + } +} + +func TestKDBXRoundTripsEntryAttachments(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Attachments: map[string][]byte{ + "token.txt": []byte("secret attachment contents"), + }, + }, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + + loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX() error = %v", err) + } + + got := loaded.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got)) + } + + if string(got[0].Attachments["token.txt"]) != "secret attachment contents" { + t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents") + } +} + +func mustGroup(name string, children ...any) gokeepasslib.Group { + group := gokeepasslib.NewGroup() + group.Name = name + for _, child := range children { + switch value := child.(type) { + case gokeepasslib.Group: + group.Groups = append(group.Groups, value) + case gokeepasslib.Entry: + group.Entries = append(group.Entries, value) + default: + panic("unsupported child type") + } + } + return group +} + +func mustEntry(title, username, url, password string) gokeepasslib.Entry { + entry := gokeepasslib.NewEntry() + entry.Values = append(entry.Values, + mkValue("Title", title), + mkValue("UserName", username), + mkValue("URL", url), + mkProtectedValue("Password", password), + ) + return entry +} + +func mkValue(key, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{Key: key, Value: gokeepasslib.V{Content: value}} +} + +func mkProtectedValue(key, value string) gokeepasslib.ValueData { + return gokeepasslib.ValueData{ + Key: key, + Value: gokeepasslib.V{Content: value, Protected: w.NewBoolWrapper(true)}, + } +} diff --git a/vault/model.go b/vault/model.go new file mode 100644 index 0000000..911d80a --- /dev/null +++ b/vault/model.go @@ -0,0 +1,449 @@ +package vault + +import ( + "errors" + "slices" + "strings" +) + +var ErrEntryNotFound = errors.New("entry not found") + +type Entry struct { + ID string + Title string + Username string + Password string + URL string + Notes string + Tags []string + Fields map[string]string + Attachments map[string][]byte + History []Entry + Path []string +} + +type SearchResult struct { + Entry Entry + Path string +} + +type Model struct { + Entries []Entry + Templates []Entry + RecycleBin []Entry + Groups [][]string +} + +func (m Model) ChildGroups(path []string) []string { + seen := map[string]bool{} + var groups []string + for _, entry := range m.Entries { + if len(path) > len(entry.Path) { + continue + } + if !slices.Equal(entry.Path[:len(path)], path) { + continue + } + if len(entry.Path) == len(path) { + continue + } + group := entry.Path[len(path)] + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + for _, groupPath := range m.Groups { + if len(path) > len(groupPath) { + continue + } + if !slices.Equal(groupPath[:len(path)], path) { + continue + } + if len(groupPath) == len(path) { + continue + } + group := groupPath[len(path)] + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + slices.Sort(groups) + return groups +} + +func (m Model) EntriesInPath(path []string) []Entry { + var entries []Entry + for _, entry := range m.Entries { + if slices.Equal(entry.Path, path) { + entries = append(entries, entry) + } + } + slices.SortFunc(entries, func(a, b Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return entries +} + +func (m Model) Search(query string) []SearchResult { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return nil + } + + var results []SearchResult + for _, entry := range m.Entries { + haystack := strings.ToLower( + entry.Title + " " + + entry.Username + " " + + entry.URL + " " + + strings.Join(entry.Path, " "), + ) + if !strings.Contains(haystack, query) { + continue + } + results = append(results, SearchResult{ + Entry: entry, + Path: strings.Join(entry.Path, " / "), + }) + } + + slices.SortFunc(results, func(a, b SearchResult) int { + switch { + case a.Entry.Title < b.Entry.Title: + return -1 + case a.Entry.Title > b.Entry.Title: + return 1 + default: + return 0 + } + }) + return results +} + +func (m *Model) UpsertEntry(entry Entry) { + for i := range m.Entries { + if m.Entries[i].ID != entry.ID { + continue + } + + previous := cloneEntry(m.Entries[i]) + entry.History = append([]Entry{previous}, cloneHistory(m.Entries[i].History)...) + m.Entries[i] = cloneEntry(entry) + return + } + + m.Entries = append(m.Entries, cloneEntry(entry)) +} + +func (m *Model) UpsertTemplate(entry Entry) { + for i := range m.Templates { + if m.Templates[i].ID != entry.ID { + continue + } + + m.Templates[i] = cloneEntry(entry) + return + } + + m.Templates = append(m.Templates, cloneEntry(entry)) +} + +func (m *Model) DeleteTemplate(id string) error { + for i := range m.Templates { + if m.Templates[i].ID != id { + continue + } + + m.Templates = append(m.Templates[:i], m.Templates[i+1:]...) + return nil + } + + return ErrEntryNotFound +} + +func (m *Model) DeleteEntry(id string) error { + for i := range m.Entries { + if m.Entries[i].ID != id { + continue + } + + m.RecycleBin = append(m.RecycleBin, cloneEntry(m.Entries[i])) + m.Entries = append(m.Entries[:i], m.Entries[i+1:]...) + return nil + } + + return ErrEntryNotFound +} + +func (m *Model) RestoreEntry(id string) error { + for i := range m.RecycleBin { + if m.RecycleBin[i].ID != id { + continue + } + + m.Entries = append(m.Entries, cloneEntry(m.RecycleBin[i])) + m.RecycleBin = append(m.RecycleBin[:i], m.RecycleBin[i+1:]...) + return nil + } + + return ErrEntryNotFound +} + +func (m *Model) InstantiateTemplate(templateID string, overrides Entry) (Entry, error) { + for i := range m.Templates { + if m.Templates[i].ID != templateID { + continue + } + + entry := mergeEntryTemplate(m.Templates[i], overrides) + m.UpsertEntry(entry) + return cloneEntry(entry), nil + } + + return Entry{}, ErrEntryNotFound +} + +func (m *Model) DuplicateEntry(id, duplicateID string) (Entry, error) { + for i := range m.Entries { + if m.Entries[i].ID != id { + continue + } + + duplicate := cloneEntry(m.Entries[i]) + duplicate.ID = duplicateID + duplicate.Title = duplicate.Title + " (Copy)" + duplicate.History = nil + m.Entries = append(m.Entries, duplicate) + return cloneEntry(duplicate), nil + } + + return Entry{}, ErrEntryNotFound +} + +func (m *Model) RestoreEntryVersion(id string, historyIndex int) error { + for i := range m.Entries { + if m.Entries[i].ID != id { + continue + } + if historyIndex < 0 || historyIndex >= len(m.Entries[i].History) { + return ErrEntryNotFound + } + + current := cloneEntry(m.Entries[i]) + restored := cloneEntry(m.Entries[i].History[historyIndex]) + restored.ID = current.ID + restored.History = append([]Entry{current}, append( + cloneHistory(m.Entries[i].History[:historyIndex]), + cloneHistory(m.Entries[i].History[historyIndex+1:])..., + )...) + m.Entries[i] = restored + return nil + } + + return ErrEntryNotFound +} + +func (m *Model) CreateGroup(parent []string, name string) { + groupPath := append(append([]string(nil), parent...), name) + for _, existing := range m.Groups { + if slices.Equal(existing, groupPath) { + return + } + } + m.Groups = append(m.Groups, groupPath) +} + +func (m *Model) RenameGroup(path []string, newName string) error { + if len(path) == 0 { + return ErrEntryNotFound + } + + renamed := false + newPath := append(append([]string(nil), path[:len(path)-1]...), newName) + for i := range m.Entries { + if !hasPathPrefix(m.Entries[i].Path, path) { + continue + } + m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...) + renamed = true + } + for i := range m.Templates { + if !hasPathPrefix(m.Templates[i].Path, path) { + continue + } + m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...) + renamed = true + } + for i := range m.Groups { + if !hasPathPrefix(m.Groups[i], path) { + continue + } + m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...) + renamed = true + } + if !renamed { + return ErrEntryNotFound + } + return nil +} + +func (m *Model) MoveEntry(id string, path []string) error { + for i := range m.Entries { + if m.Entries[i].ID != id { + continue + } + m.Entries[i].Path = append([]string(nil), path...) + return nil + } + return ErrEntryNotFound +} + +func (m *Model) MoveTemplate(id string, path []string) error { + for i := range m.Templates { + if m.Templates[i].ID != id { + continue + } + m.Templates[i].Path = append([]string(nil), path...) + return nil + } + return ErrEntryNotFound +} + +func (m *Model) DeleteGroup(path []string) error { + for _, entry := range m.Entries { + if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { + return errors.New("group is not empty") + } + } + for _, entry := range m.Templates { + if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { + return errors.New("group is not empty") + } + } + + for i := range m.Groups { + if slices.Equal(m.Groups[i], path) { + m.Groups = append(m.Groups[:i], m.Groups[i+1:]...) + return nil + } + } + return ErrEntryNotFound +} + +func hasPathPrefix(path, prefix []string) bool { + if len(prefix) > len(path) { + return false + } + return slices.Equal(path[:len(prefix)], prefix) +} + +func mergeEntryTemplate(template, overrides Entry) Entry { + entry := cloneEntry(template) + + if overrides.ID != "" { + entry.ID = overrides.ID + } + if overrides.Title != "" { + entry.Title = overrides.Title + } + if overrides.Username != "" { + entry.Username = overrides.Username + } + if overrides.Password != "" { + entry.Password = overrides.Password + } + if overrides.URL != "" { + entry.URL = overrides.URL + } + if overrides.Notes != "" { + entry.Notes = overrides.Notes + } + if len(overrides.Tags) > 0 { + entry.Tags = slices.Clone(overrides.Tags) + } + if len(overrides.Path) > 0 { + entry.Path = slices.Clone(overrides.Path) + } + + entry.Fields = mergeStringMaps(template.Fields, overrides.Fields) + entry.Attachments = mergeBinaryMaps(template.Attachments, overrides.Attachments) + entry.History = nil + + return entry +} + +func mergeStringMaps(base, overrides map[string]string) map[string]string { + if len(base) == 0 && len(overrides) == 0 { + return nil + } + + out := make(map[string]string, len(base)+len(overrides)) + for key, value := range base { + out[key] = value + } + for key, value := range overrides { + out[key] = value + } + + return out +} + +func mergeBinaryMaps(base, overrides map[string][]byte) map[string][]byte { + if len(base) == 0 && len(overrides) == 0 { + return nil + } + + out := make(map[string][]byte, len(base)+len(overrides)) + for key, value := range base { + out[key] = slices.Clone(value) + } + for key, value := range overrides { + out[key] = slices.Clone(value) + } + + return out +} + +func cloneEntry(entry Entry) Entry { + entry.Tags = slices.Clone(entry.Tags) + entry.Path = slices.Clone(entry.Path) + entry.History = cloneHistory(entry.History) + if entry.Fields != nil { + fields := make(map[string]string, len(entry.Fields)) + for key, value := range entry.Fields { + fields[key] = value + } + entry.Fields = fields + } + if entry.Attachments != nil { + attachments := make(map[string][]byte, len(entry.Attachments)) + for key, value := range entry.Attachments { + attachments[key] = slices.Clone(value) + } + entry.Attachments = attachments + } + return entry +} + +func cloneHistory(history []Entry) []Entry { + if len(history) == 0 { + return nil + } + + out := make([]Entry, len(history)) + for i := range history { + out[i] = cloneEntry(history[i]) + } + return out +} diff --git a/vault/model_test.go b/vault/model_test.go new file mode 100644 index 0000000..e6666e4 --- /dev/null +++ b/vault/model_test.go @@ -0,0 +1,292 @@ +package vault + +import ( + "errors" + "slices" + "testing" +) + +func testModel() Model { + return Model{ + Entries: []Entry{ + {ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + {ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}}, + }, + } +} + +func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) { + model := testModel() + + got := model.ChildGroups([]string{"Crew"}) + want := []string{"Home Assistant", "Internet"} + + if !slices.Equal(got, want) { + t.Fatalf("ChildGroups() = %v, want %v", got, want) + } +} + +func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { + model := testModel() + + got := model.EntriesInPath([]string{"Crew", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) + } + + if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" { + t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title) + } +} + +func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { + model := testModel() + + got := model.Search("vault") + if len(got) != 1 { + t.Fatalf("len(Search()) = %d, want 1", len(got)) + } + + if got[0].Entry.Title != "Vault Console" { + t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console") + } + + if got[0].Path != "Crew / Internet" { + t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet") + } +} + +func TestTemplateEntriesAreStoredSeparatelyFromNormalEntries(t *testing.T) { + model := testModel() + + model.UpsertTemplate(Entry{ + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Tags: []string{"template", "web"}, + Path: []string{"Templates"}, + }) + + if len(model.Entries) != 4 { + t.Fatalf("len(Entries) = %d, want 4", len(model.Entries)) + } + + if len(model.Templates) != 1 { + t.Fatalf("len(Templates) = %d, want 1", len(model.Templates)) + } + + if got := model.Templates[0].Title; got != "Website Login" { + t.Fatalf("Templates[0].Title = %q, want %q", got, "Website Login") + } +} + +func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) { + model := Model{ + Templates: []Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + URL: "https://example.com", + Notes: "Reusable template for website accounts.", + Tags: []string{"template", "web"}, + Fields: map[string]string{ + "Environment": "prod", + }, + Path: []string{"Templates"}, + }, + }, + } + + entry, err := model.InstantiateTemplate("tpl-1", Entry{ + ID: "entry-1", + Title: "Bellagio", + Username: "rustyryan", + Password: "hunter2", + URL: "https://bellagio.example.invalid", + Path: []string{"Crew", "Internet"}, + Tags: []string{"dns"}, + }) + if err != nil { + t.Fatalf("InstantiateTemplate() error = %v", err) + } + + if entry.ID != "entry-1" { + t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1") + } + + if entry.Title != "Bellagio" { + t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio") + } + + if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" { + t.Fatalf("entry credentials = %#v, want override values", entry) + } + + if entry.Notes != "Reusable template for website accounts." { + t.Fatalf("entry.Notes = %q, want %q", entry.Notes, "Reusable template for website accounts.") + } + + if !slices.Equal(entry.Tags, []string{"dns"}) { + t.Fatalf("entry.Tags = %v, want [dns]", entry.Tags) + } + + if entry.Fields["Environment"] != "prod" { + t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod") + } + + got := model.EntriesInPath([]string{"Crew", "Internet"}) + if len(got) != 1 || got[0].Title != "Bellagio" { + t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got) + } +} + +func TestInstantiateTemplateFailsForUnknownTemplate(t *testing.T) { + model := Model{} + + _, err := model.InstantiateTemplate("missing-template", Entry{ID: "entry-1"}) + if err == nil { + t.Fatal("InstantiateTemplate() error = nil, want ErrEntryNotFound") + } + + if !errors.Is(err, ErrEntryNotFound) { + t.Fatalf("InstantiateTemplate() error = %v, want ErrEntryNotFound", err) + } +} + +func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + Templates: []Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}}, + }, + } + + if err := model.DeleteTemplate("tpl-1"); err != nil { + t.Fatalf("DeleteTemplate() error = %v", err) + } + + if len(model.Templates) != 0 { + t.Fatalf("len(Templates) = %d, want 0", len(model.Templates)) + } + if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" { + t.Fatalf("Entries = %#v, want unchanged normal entry", model.Entries) + } +} + +func TestMoveTemplateChangesItsPath(t *testing.T) { + t.Parallel() + + model := Model{ + Templates: []Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + }, + } + + if err := model.MoveTemplate("tpl-1", []string{"Templates", "Infra"}); err != nil { + t.Fatalf("MoveTemplate() error = %v", err) + } + + if got := model.Templates[0].Path; !slices.Equal(got, []string{"Templates", "Infra"}) { + t.Fatalf("Templates[0].Path = %v, want [Templates Infra]", got) + } +} + +func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + Path: []string{"Root", "Internet"}, + }, + }, + } + + duplicate, err := model.DuplicateEntry("entry-1", "entry-2") + if err != nil { + t.Fatalf("DuplicateEntry() error = %v", err) + } + + if duplicate.ID != "entry-2" { + t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2") + } + if duplicate.Title != "Vault Console (Copy)" { + t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)") + } + got := model.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got)) + } +} + +func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { + model := testModel() + + model.CreateGroup([]string{"Crew"}, "Finance") + + got := model.ChildGroups([]string{"Crew"}) + want := []string{"Finance", "Home Assistant", "Internet"} + if !slices.Equal(got, want) { + t.Fatalf("ChildGroups() = %v, want %v", got, want) + } +} + +func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { + model := testModel() + + if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil { + t.Fatalf("RenameGroup() error = %v", err) + } + + got := model.EntriesInPath([]string{"Crew", "Infra"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got)) + } + + if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 { + t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename") + } +} + +func TestMoveEntryChangesItsPath(t *testing.T) { + model := testModel() + + if err := model.MoveEntry("1", []string{"Tricia", "School"}); err != nil { + t.Fatalf("MoveEntry() error = %v", err) + } + + got := model.EntriesInPath([]string{"Tricia", "School"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Tricia/School)) = %d, want 2", len(got)) + } +} + +func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { + model := testModel() + + model.CreateGroup([]string{"Crew"}, "Finance") + if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil { + t.Fatalf("DeleteGroup() error = %v", err) + } + + got := model.ChildGroups([]string{"Crew"}) + want := []string{"Home Assistant", "Internet"} + if !slices.Equal(got, want) { + t.Fatalf("ChildGroups() = %v, want %v", got, want) + } +} diff --git a/webdav/client.go b/webdav/client.go new file mode 100644 index 0000000..02b6214 --- /dev/null +++ b/webdav/client.go @@ -0,0 +1,93 @@ +package webdav + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +var ErrConflict = errors.New("webdav conflict") + +type Version struct { + ETag string +} + +type Client struct { + HTTPClient *http.Client + BaseURL string + Username string + Password string +} + +func (c Client) Open(path string) ([]byte, Version, error) { + req, err := http.NewRequest(http.MethodGet, c.url(path), nil) + if err != nil { + return nil, Version{}, fmt.Errorf("build GET request: %w", err) + } + c.applyAuth(req) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, Version{}, fmt.Errorf("GET %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, Version{}, fmt.Errorf("GET %s: unexpected status %d", path, resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, Version{}, fmt.Errorf("read %s: %w", path, err) + } + + return content, Version{ETag: resp.Header.Get("ETag")}, nil +} + +func (c Client) Save(path string, content io.Reader, version Version) (Version, error) { + req, err := http.NewRequest(http.MethodPut, c.url(path), content) + if err != nil { + return Version{}, fmt.Errorf("build PUT request: %w", err) + } + c.applyAuth(req) + if version.ETag != "" { + req.Header.Set("If-Match", version.ETag) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return Version{}, fmt.Errorf("PUT %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusPreconditionFailed { + return Version{}, ErrConflict + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return Version{}, fmt.Errorf("PUT %s: unexpected status %d", path, resp.StatusCode) + } + + return Version{ETag: resp.Header.Get("ETag")}, nil +} + +func (c Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +func (c Client) applyAuth(req *http.Request) { + if c.Username == "" && c.Password == "" { + return + } + req.SetBasicAuth(c.Username, c.Password) +} + +func (c Client) url(path string) string { + base := strings.TrimRight(c.BaseURL, "/") + path = strings.TrimLeft(path, "/") + return base + "/" + path +} diff --git a/webdav/client_test.go b/webdav/client_test.go new file mode 100644 index 0000000..72423c0 --- /dev/null +++ b/webdav/client_test.go @@ -0,0 +1,105 @@ +package webdav + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientOpenDownloadsRemoteVault(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if user, pass, ok := r.BasicAuth(); !ok || user != "rustyryan" || pass != "secret" { + t.Fatalf("basic auth = %q/%q ok=%v, want rustyryan/secret true", user, pass, ok) + } + w.Header().Set("ETag", `"etag-1"`) + _, _ = io.WriteString(w, "vault-bytes") + })) + defer server.Close() + + client := Client{ + HTTPClient: server.Client(), + BaseURL: server.URL, + Username: "rustyryan", + Password: "secret", + } + + content, version, err := client.Open("keepass.kdbx") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + + if string(content) != "vault-bytes" { + t.Fatalf("Open() content = %q, want %q", string(content), "vault-bytes") + } + + if version.ETag != `"etag-1"` { + t.Fatalf("Open() ETag = %q, want %q", version.ETag, `"etag-1"`) + } +} + +func TestClientSaveUploadsVaultWithIfMatch(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) + } + if got := r.Header.Get("If-Match"); got != `"etag-1"` { + t.Fatalf("If-Match = %q, want %q", got, `"etag-1"`) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if string(body) != "updated-vault" { + t.Fatalf("PUT body = %q, want %q", string(body), "updated-vault") + } + w.Header().Set("ETag", `"etag-2"`) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := Client{ + HTTPClient: server.Client(), + BaseURL: server.URL, + Username: "rustyryan", + Password: "secret", + } + + version, err := client.Save("keepass.kdbx", bytes.NewBufferString("updated-vault"), Version{ETag: `"etag-1"`}) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + if version.ETag != `"etag-2"` { + t.Fatalf("Save() ETag = %q, want %q", version.ETag, `"etag-2"`) + } +} + +func TestClientSaveReturnsConflictOnVersionMismatch(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPreconditionFailed) + })) + defer server.Close() + + client := Client{ + HTTPClient: server.Client(), + BaseURL: server.URL, + Username: "rustyryan", + Password: "secret", + } + + _, err := client.Save("keepass.kdbx", bytes.NewBufferString("updated-vault"), Version{ETag: `"etag-1"`}) + if err != ErrConflict { + t.Fatalf("Save() error = %v, want %v", err, ErrConflict) + } +}