Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions internal/client/cli/commands/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,36 @@ func NewPullCmd() *cobra.Command {
if !ok {
return errors.New(color.RedString("Vault %q is not registered locally, run `potok vault-add %s` first", name, name))
}
storedName := v.Name

apiKey, err := secrets.Get(secrets.APIKey)
if err != nil {
return errors.New(color.RedString("Error getting API key: %v", err))
}

passphrase, err := secrets.Get(secrets.VaultKeyName(name))
passphrase, err := secrets.Get(secrets.VaultKeyName(storedName))
if err != nil {
return errors.New(color.RedString("No stored passphrase for vault %q: %v", name, err))
return errors.New(color.RedString("No stored passphrase for vault %q: %v", storedName, err))
}

client := transport.New(cfg.ServerURL, apiKey)

remote, found, err := client.FetchVault(name)
remote, found, err := client.FetchVault(storedName)
if err != nil {
return err
}
if !found {
return errors.New(color.RedString("Vault %q has not been pushed to the server yet", name))
return errors.New(color.RedString("Vault %q has not been pushed to the server yet", storedName))
}

key := crypto.DeriveKey([]byte(passphrase), remote.KDFSalt)

manifestCiphertext, found, err := client.FetchManifest(name)
manifestCiphertext, found, err := client.FetchManifest(storedName)
if err != nil {
return err
}
if !found {
fmt.Println(color.YellowString("Vault %q has no manifest yet - nothing to pull.", name))
fmt.Println(color.YellowString("Vault %q has no manifest yet - nothing to pull.", storedName))
return nil
}

Expand All @@ -79,7 +80,7 @@ func NewPullCmd() *cobra.Command {
}

for _, f := range manifest.Files {
ciphertext, err := client.DownloadBlob(name, f.BlobID)
ciphertext, err := client.DownloadBlob(storedName, f.BlobID)
if err != nil {
return fmt.Errorf("failed to download %s: %w", f.Path, err)
}
Expand All @@ -98,7 +99,7 @@ func NewPullCmd() *cobra.Command {
}
}

fmt.Println(color.GreenString("Pulled %d file(s) into vault %q.", len(manifest.Files), name))
fmt.Println(color.GreenString("Pulled %d file(s) into vault %q.", len(manifest.Files), storedName))
return nil
},
}
Expand Down
15 changes: 8 additions & 7 deletions internal/client/cli/commands/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,21 @@ func NewPushCmd() *cobra.Command {
if !ok {
return errors.New(color.RedString("Vault %q is not registered locally, run `potok vault-add %s` first", name, name))
}
storedName := v.Name

apiKey, err := secrets.Get(secrets.APIKey)
if err != nil {
return errors.New(color.RedString("Error getting API key: %v", err))
}

passphrase, err := secrets.Get(secrets.VaultKeyName(name))
passphrase, err := secrets.Get(secrets.VaultKeyName(storedName))
if err != nil {
return errors.New(color.RedString("No stored passphrase for vault %q: %v", name, err))
return errors.New(color.RedString("No stored passphrase for vault %q: %v", storedName, err))
}

client := transport.New(cfg.ServerURL, apiKey)

remote, err := client.GetOrCreateVault(name)
remote, err := client.GetOrCreateVault(storedName)
if err != nil {
return err
}
Expand All @@ -75,7 +76,7 @@ func NewPushCmd() *cobra.Command {
return fmt.Errorf("failed to encrypt %s: %w", f.Path, err)
}

blobID, err := client.UploadBlob(name, ciphertext)
blobID, err := client.UploadBlob(storedName, ciphertext)
if err != nil {
return fmt.Errorf("failed to upload %s: %w", f.Path, err)
}
Expand All @@ -98,19 +99,19 @@ func NewPushCmd() *cobra.Command {
return fmt.Errorf("failed to encrypt manifest: %w", err)
}

generation, err := client.CurrentManifestGeneration(name)
generation, err := client.CurrentManifestGeneration(storedName)
if err != nil {
return err
}

if err := client.PutManifest(name, generation, manifestCiphertext); err != nil {
if err := client.PutManifest(storedName, generation, manifestCiphertext); err != nil {
if errors.Is(err, transport.ErrConflict) {
return errors.New(color.RedString(transport.ErrConflict.Error()))
}
return err
}

fmt.Println(color.GreenString("Pushed %d file(s) from vault %q.", len(files), name))
fmt.Println(color.GreenString("Pushed %d file(s) from vault %q.", len(files), storedName))
return nil
},
}
Expand Down
43 changes: 43 additions & 0 deletions internal/client/cli/commands/push_pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,49 @@ func TestPushThenPullWrongPassphraseFails(t *testing.T) {
}
}

func TestPushThenPullCaseInsensitiveVaultName(t *testing.T) {
client := newTestServer(t)

source := t.TempDir()
writeVaultFile(t, source, "note.md", []byte("hello, potok"))

pushVault(t, client, "Notes", source, "hunter2")

created, found, err := client.FetchVault("NOTES")
if err != nil {
t.Fatalf("FetchVault(NOTES): %v", err)
}
if !found {
t.Fatal("FetchVault(NOTES) not found")
}
if created.Name != "Notes" {
t.Errorf("FetchVault(NOTES).Name = %q, want stored casing %q", created.Name, "Notes")
}

again, err := client.GetOrCreateVault("notes")
if err != nil {
t.Fatalf("GetOrCreateVault(notes) after Notes exists: %v", err)
}
if again.ID != created.ID {
t.Errorf("GetOrCreateVault(notes) created a new vault; ID = %q, want %q", again.ID, created.ID)
}
if again.Name != "Notes" {
t.Errorf("GetOrCreateVault(notes).Name = %q, want original %q", again.Name, "Notes")
}

dest := t.TempDir()
if err := pullVault(client, "NOTES", dest, "hunter2"); err != nil {
t.Fatalf("pullVault(NOTES): %v", err)
}
content, err := os.ReadFile(filepath.Join(dest, "note.md"))
if err != nil {
t.Fatalf("read pulled note.md: %v", err)
}
if string(content) != "hello, potok" {
t.Errorf("pulled content = %q, want %q", content, "hello, potok")
}
}

func TestPushCreatesRemoteVaultOnFirstPush(t *testing.T) {
client := newTestServer(t)

Expand Down
14 changes: 10 additions & 4 deletions internal/client/cli/commands/vault_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,25 @@ func NewVaultRemoveCmd() *cobra.Command {
return err
}

if !cfg.RemoveVault(name) {
v, ok := cfg.Vault(name)
if !ok {
return errors.New(color.RedString("Vault %q is not registered locally", name))
}
storedName := v.Name

if err := secrets.Delete(secrets.VaultKeyName(name)); err != nil && !errors.Is(err, secrets.ErrNotFound) {
return fmt.Errorf("failed to delete stored passphrase for vault %q: %w", name, err)
if !cfg.RemoveVault(storedName) {
return errors.New(color.RedString("Vault %q is not registered locally", name))
}

if err := secrets.Delete(secrets.VaultKeyName(storedName)); err != nil && !errors.Is(err, secrets.ErrNotFound) {
return fmt.Errorf("failed to delete stored passphrase for vault %q: %w", storedName, err)
}

if err := config.Save(cfg); err != nil {
return err
}

fmt.Println(color.GreenString("Removed vault %q and its stored passphrase.", name))
fmt.Println(color.GreenString("Removed vault %q and its stored passphrase.", storedName))
return nil
},
}
Expand Down
24 changes: 24 additions & 0 deletions internal/client/cli/commands/vault_remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ func TestVaultRemoveDeletesStoredPassphrase(t *testing.T) {
}
}

func TestVaultRemoveMatchesNameCaseInsensitively(t *testing.T) {
setupVaultRemove(t)
saveConfigWithVault(t)

if err := secrets.Set(secrets.VaultKeyName("notes"), "supersecret"); err != nil {
t.Fatalf("secrets.Set() = %v", err)
}

if err := executeCmd(t, NewVaultRemoveCmd(), "NOTES"); err != nil {
t.Fatalf("vault-remove NOTES = %v", err)
}

cfg, err := config.Load()
if err != nil {
t.Fatalf("config.Load() = %v", err)
}
if len(cfg.Vaults) != 0 {
t.Errorf("Vaults = %+v, want none after case-insensitive removal", cfg.Vaults)
}
if _, err := secrets.Get(secrets.VaultKeyName("notes")); !errors.Is(err, secrets.ErrNotFound) {
t.Errorf("secrets.Get() after case-insensitive remove = %v, want ErrNotFound", err)
}
}

func TestVaultRemoveFailsForUnknownVault(t *testing.T) {
setupVaultRemove(t)
saveConfigWithVault(t)
Expand Down
13 changes: 9 additions & 4 deletions internal/client/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,11 @@ func (c *Config) Validate() error {
if err := ValidateVaultName(v.Name); err != nil {
return err
}
if _, dup := seen[v.Name]; dup {
key := strings.ToLower(v.Name)
if _, dup := seen[key]; dup {
return fmt.Errorf("config: vault %q is registered twice", v.Name)
}
seen[v.Name] = struct{}{}
seen[key] = struct{}{}

if !filepath.IsAbs(v.Path) {
return fmt.Errorf("config: vault %q needs an absolute path, got %q", v.Name, v.Path)
Expand All @@ -171,7 +172,7 @@ func ValidateVaultName(name string) error {

func (c *Config) Vault(name string) (*Vault, bool) {
for i := range c.Vaults {
if c.Vaults[i].Name == name {
if vaultNameEqual(c.Vaults[i].Name, name) {
return &c.Vaults[i], true
}
}
Expand All @@ -194,10 +195,14 @@ func (c *Config) AddVault(v Vault) error {

func (c *Config) RemoveVault(name string) bool {
for i := range c.Vaults {
if c.Vaults[i].Name == name {
if vaultNameEqual(c.Vaults[i].Name, name) {
c.Vaults = append(c.Vaults[:i], c.Vaults[i+1:]...)
return true
}
}
return false
}

func vaultNameEqual(a, b string) bool {
return strings.EqualFold(a, b)
}
58 changes: 48 additions & 10 deletions internal/client/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,17 @@ func TestValidate(t *testing.T) {
cfg Config
wantErr bool
}{
"zero value": {cfg: Config{}},
"valid": {cfg: Config{ServerURL: "http://localhost:8080", Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}}},
"bad scheme": {cfg: Config{ServerURL: "ftp://example.com"}, wantErr: true},
"no host": {cfg: Config{ServerURL: "https://"}, wantErr: true},
"relative path": {cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, wantErr: true},
"duplicate names": {cfg: Config{Vaults: []Vault{{Name: "a", Path: "/a"}, {Name: "a", Path: "/b"}}}, wantErr: true},
"empty name": {cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, wantErr: true},
"name with space": {cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, wantErr: true},
"name with separator": {cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, wantErr: true},
"name starts with -": {cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, wantErr: true},
"zero value": {cfg: Config{}},
"valid": {cfg: Config{ServerURL: "http://localhost:8080", Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}}},
"bad scheme": {cfg: Config{ServerURL: "ftp://example.com"}, wantErr: true},
"no host": {cfg: Config{ServerURL: "https://"}, wantErr: true},
"relative path": {cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, wantErr: true},
"duplicate names": {cfg: Config{Vaults: []Vault{{Name: "a", Path: "/a"}, {Name: "a", Path: "/b"}}}, wantErr: true},
"duplicate names case": {cfg: Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}, {Name: "notes", Path: "/b"}}}, wantErr: true},
"empty name": {cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, wantErr: true},
"name with space": {cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, wantErr: true},
"name with separator": {cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, wantErr: true},
"name starts with -": {cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, wantErr: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
Expand All @@ -154,6 +155,9 @@ func TestAddVault(t *testing.T) {
if err := cfg.AddVault(Vault{Name: "notes", Path: "/elsewhere"}); err == nil {
t.Error("AddVault() = nil, want an error for a duplicate name")
}
if err := cfg.AddVault(Vault{Name: "NOTES", Path: "/elsewhere"}); err == nil {
t.Error("AddVault() = nil, want an error for a case-insensitive duplicate name")
}
if err := cfg.AddVault(Vault{Name: "work", Path: "relative"}); err == nil {
t.Error("AddVault() = nil, want an error for a relative path")
}
Expand All @@ -176,6 +180,17 @@ func TestRemoveVault(t *testing.T) {
}
}

func TestRemoveVaultCaseInsensitive(t *testing.T) {
cfg := &Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}, {Name: "work", Path: "/b"}}}

if !cfg.RemoveVault("NOTES") {
t.Error("RemoveVault(NOTES) = false, want true")
}
if len(cfg.Vaults) != 1 || cfg.Vaults[0].Name != "work" {
t.Errorf("Vaults = %+v, want work only", cfg.Vaults)
}
}

func TestVaultReturnsPointer(t *testing.T) {
cfg := &Config{Vaults: []Vault{{Name: "notes", Path: "/a"}}}

Expand All @@ -193,6 +208,29 @@ func TestVaultReturnsPointer(t *testing.T) {
}
}

func TestVaultCaseInsensitive(t *testing.T) {
cfg := &Config{Vaults: []Vault{{Name: "Notes", Path: "/a"}}}

for _, name := range []string{"Notes", "notes", "NOTES", "nOtEs"} {
v, ok := cfg.Vault(name)
if !ok {
t.Fatalf("Vault(%q) not found", name)
}
if v.Name != "Notes" {
t.Errorf("Vault(%q).Name = %q, want stored casing %q", name, v.Name, "Notes")
}
}

v, ok := cfg.Vault("NOTES")
if !ok {
t.Fatal("Vault(NOTES) not found")
}
v.RemoteID = "vault_123"
if cfg.Vaults[0].RemoteID != "vault_123" {
t.Error("Vault() returned a copy; updates would be lost")
}
}

func TestDirPrecedence(t *testing.T) {
t.Setenv("POTOK_CONFIG_DIR", "/custom/potok")
t.Setenv("XDG_CONFIG_HOME", "/xdg")
Expand Down
1 change: 0 additions & 1 deletion internal/server/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ func (h *Handler) VaultByName(w http.ResponseWriter, r *http.Request) {
return
}

// TODO: Implement case insensitive name matching (will need modifiying in store)
name := r.PathValue("name")
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
Expand Down
Loading