diff --git a/internal/client/config/config.go b/internal/client/config/config.go new file mode 100644 index 0000000..7eb6161 --- /dev/null +++ b/internal/client/config/config.go @@ -0,0 +1,212 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +var ErrNotFound = errors.New("config: not initialised, run `potok init`") + +const maxVaultNameLen = 64 +const invalidNameChars = `/\:*?"<>|` + " " + +type Config struct { + ServerURL string `json:"server_url"` + Vaults []Vault `json:"vaults"` +} + +type Vault struct { + Name string `json:"name"` + Path string `json:"path"` + RemoteID string `json:"remote_id,omitempty"` + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` +} + +type Store interface { + Load() (*Config, error) + Save(cfg *Config) error + Path() string +} + +type FileStore struct { + Dir string +} + +var _ Store = (*FileStore)(nil) + +func Dir() (string, error) { + if dir := os.Getenv("POTOK_CONFIG_DIR"); dir != "" { + return dir, nil + } + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "potok"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("config: locate home directory: %w", err) + } + return filepath.Join(home, ".potok"), nil +} + +func (s *FileStore) Path() string { + dir := s.Dir + if dir == "" { + resolved, err := Dir() + if err != nil { + return "config.json" + } + dir = resolved + } + return filepath.Join(dir, "config.json") +} + +func (s *FileStore) Load() (*Config, error) { + path := s.Path() + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("config: read %s: %w", path, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + return &cfg, nil +} + +func (s *FileStore) Save(cfg *Config) error { + if err := cfg.Validate(); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("config: encode: %w", err) + } + data = append(data, '\n') + + path := s.Path() + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("config: create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, ".config-*.json") + if err != nil { + return fmt.Errorf("config: create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { + + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("config: set permissions on temp file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("config: write temp file: %w", err) + } + + if err := tmp.Sync(); err != nil { + return fmt.Errorf("config: sync temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("config: close temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("config: replace %s: %w", path, err) + } + return nil +} + +func (c *Config) Validate() error { + if c.ServerURL != "" { + u, err := url.Parse(c.ServerURL) + if err != nil { + return fmt.Errorf("config: invalid server_url %q: %w", c.ServerURL, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("config: server_url must use http or https, got %q", u.Scheme) + } + if u.Host == "" { + return fmt.Errorf("config: server_url %q is missing a host", c.ServerURL) + } + } + + seen := make(map[string]struct{}, len(c.Vaults)) + for _, v := range c.Vaults { + if err := ValidateVaultName(v.Name); err != nil { + return err + } + if _, dup := seen[v.Name]; dup { + return fmt.Errorf("config: vault %q is registered twice", v.Name) + } + seen[v.Name] = struct{}{} + + if !filepath.IsAbs(v.Path) { + return fmt.Errorf("config: vault %q needs an absolute path, got %q", v.Name, v.Path) + } + } + return nil +} + +func ValidateVaultName(name string) error { + switch { + case name == "": + return errors.New("config: vault name is empty") + case len(name) > maxVaultNameLen: + return fmt.Errorf("config: vault name %q is longer than %d characters", name, maxVaultNameLen) + case strings.ContainsAny(name, invalidNameChars): + return fmt.Errorf("config: vault name %q contains a space or one of %s", name, invalidNameChars) + case strings.HasPrefix(name, "-"): + return fmt.Errorf("config: vault name %q may not start with a dash", name) + case strings.HasPrefix(name, "."): + return fmt.Errorf("config: vault name %q may not start with a dot", name) + } + return nil +} + +func (c *Config) Vault(name string) (*Vault, bool) { + for i := range c.Vaults { + if c.Vaults[i].Name == name { + return &c.Vaults[i], true + } + } + return nil, false +} + +func (c *Config) AddVault(v Vault) error { + if err := ValidateVaultName(v.Name); err != nil { + return err + } + if _, exists := c.Vault(v.Name); exists { + return fmt.Errorf("config: vault %q is already registered", v.Name) + } + if !filepath.IsAbs(v.Path) { + return fmt.Errorf("config: vault %q needs an absolute path, got %q", v.Name, v.Path) + } + c.Vaults = append(c.Vaults, v) + return nil +} + +func (c *Config) RemoveVault(name string) bool { + for i := range c.Vaults { + if c.Vaults[i].Name == name { + c.Vaults = append(c.Vaults[:i], c.Vaults[i+1:]...) + return true + } + } + return false +} diff --git a/internal/client/config/config_test.go b/internal/client/config/config_test.go new file mode 100644 index 0000000..9800f92 --- /dev/null +++ b/internal/client/config/config_test.go @@ -0,0 +1,272 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func newStore(t *testing.T) *FileStore { + t.Helper() + return &FileStore{Dir: t.TempDir()} +} + +func TestLoadReturnsNotFoundWhenMissing(t *testing.T) { + store := newStore(t) + + _, err := store.Load() + + if !errors.Is(err, ErrNotFound) { + t.Fatalf("Load() = %v, want ErrNotFound", err) + } +} + +func TestSaveThenLoadRoundTrip(t *testing.T) { + store := newStore(t) + synced := time.Date(2026, time.March, 4, 12, 0, 0, 0, time.UTC) + want := &Config{ + ServerURL: "https://potok.example.com", + Vaults: []Vault{ + {Name: "notes", Path: filepath.Join(store.Dir, "notes"), LastSyncedAt: &synced}, + {Name: "work", Path: filepath.Join(store.Dir, "work")}, + }, + } + + if err := store.Save(want); err != nil { + t.Fatalf("Save() = %v", err) + } + + got, err := store.Load() + if err != nil { + t.Fatalf("Load() = %v", err) + } + if got.ServerURL != want.ServerURL { + t.Errorf("ServerURL = %q, want %q", got.ServerURL, want.ServerURL) + } + if len(got.Vaults) != len(want.Vaults) { + t.Fatalf("len(Vaults) = %d, want %d", len(got.Vaults), len(want.Vaults)) + } + if got.Vaults[0].LastSyncedAt == nil || !got.Vaults[0].LastSyncedAt.Equal(synced) { + t.Errorf("Vaults[0].LastSyncedAt = %v, want %v", got.Vaults[0].LastSyncedAt, synced) + } + if got.Vaults[1].LastSyncedAt != nil { + t.Errorf("Vaults[1].LastSyncedAt = %v, want nil for a never-synced vault", got.Vaults[1].LastSyncedAt) + } +} + +func TestSaveOverwritesExistingConfig(t *testing.T) { + store := newStore(t) + first := &Config{ServerURL: "https://one.example.com"} + if err := store.Save(first); err != nil { + t.Fatalf("Save(first) = %v", err) + } + + second := &Config{ServerURL: "https://two.example.com"} + if err := store.Save(second); err != nil { + t.Fatalf("Save(second) = %v", err) + } + + got, err := store.Load() + if err != nil { + t.Fatalf("Load() = %v", err) + } + if got.ServerURL != second.ServerURL { + t.Errorf("ServerURL = %q, want %q", got.ServerURL, second.ServerURL) + } + + entries, err := os.ReadDir(store.Dir) + if err != nil { + t.Fatalf("ReadDir() = %v", err) + } + if len(entries) != 1 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("config directory holds %v, want config.json only", names) + } +} + +func TestSaveUsesRestrictivePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix file modes are not meaningful on Windows") + } + store := newStore(t) + if err := store.Save(&Config{ServerURL: "https://potok.example.com"}); err != nil { + t.Fatalf("Save() = %v", err) + } + + info, err := os.Stat(store.Path()) + if err != nil { + t.Fatalf("Stat() = %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config.json mode = %o, want 600", perm) + } +} + +func TestSaveRejectsInvalidConfig(t *testing.T) { + store := newStore(t) + + err := store.Save(&Config{ServerURL: "ftp://example.com"}) + + if err == nil { + t.Fatal("Save() = nil, want an error for an invalid config") + } + if _, statErr := os.Stat(store.Path()); statErr == nil { + t.Error("Save() wrote a file despite failing validation") + } +} + +func TestLoadRejectsMalformedJSON(t *testing.T) { + store := newStore(t) + if err := os.WriteFile(store.Path(), []byte("{not json"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v", err) + } + + if _, err := store.Load(); err == nil { + t.Fatal("Load() = nil, want an error for malformed JSON") + } +} + +func TestValidate(t *testing.T) { + tests := map[string]struct { + cfg Config + wantErr bool + }{ + "zero value": { + cfg: Config{}, + }, + "valid": { + cfg: Config{ + ServerURL: "http://localhost:8080", + Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}, + }, + }, + "unsupported url scheme": { + cfg: Config{ServerURL: "ftp://example.com"}, + wantErr: true, + }, + "url without a host": { + cfg: Config{ServerURL: "https://"}, + wantErr: true, + }, + "relative vault path": { + cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, + wantErr: true, + }, + "duplicate vault names": { + cfg: Config{Vaults: []Vault{ + {Name: "notes", Path: "/a"}, + {Name: "notes", Path: "/b"}, + }}, + wantErr: true, + }, + "empty vault name": { + cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, + wantErr: true, + }, + "vault name with a space": { + cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, + wantErr: true, + }, + "vault name with a path separator": { + cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, + wantErr: true, + }, + "vault name starting with a dash": { + cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := tc.cfg.Validate() + if (err != nil) != tc.wantErr { + t.Fatalf("Validate() = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestAddVault(t *testing.T) { + cfg := &Config{} + + if err := cfg.AddVault(Vault{Name: "notes", Path: "/home/user/notes"}); err != nil { + t.Fatalf("AddVault() = %v", err) + } + if len(cfg.Vaults) != 1 { + t.Fatalf("len(Vaults) = %d, want 1", len(cfg.Vaults)) + } + + if err := cfg.AddVault(Vault{Name: "notes", Path: "/somewhere/else"}); err == nil { + t.Error("AddVault() = nil, want an error for a duplicate name") + } + if len(cfg.Vaults) != 1 { + t.Errorf("len(Vaults) = %d after a rejected add, want 1", len(cfg.Vaults)) + } +} + +func TestRemoveVault(t *testing.T) { + cfg := &Config{Vaults: []Vault{ + {Name: "notes", Path: "/a"}, + {Name: "work", Path: "/b"}, + }} + + if removed := cfg.RemoveVault("notes"); !removed { + t.Error("RemoveVault(notes) = false, want true") + } + if removed := cfg.RemoveVault("notes"); removed { + t.Error("RemoveVault(notes) = true on the second call, want false") + } + if len(cfg.Vaults) != 1 || cfg.Vaults[0].Name != "work" { + t.Errorf("Vaults = %+v, want work only", cfg.Vaults) + } +} + +func TestVaultLookup(t *testing.T) { + cfg := &Config{Vaults: []Vault{{Name: "notes", Path: "/a"}}} + + got, ok := cfg.Vault("notes") + if !ok { + t.Fatal("Vault(notes) not found") + } + got.RemoteID = "vault_123" + if cfg.Vaults[0].RemoteID != "vault_123" { + t.Error("Vault() returned a copy, want a pointer into the slice") + } + + if _, ok := cfg.Vault("missing"); ok { + t.Error("Vault(missing) = true, want false") + } +} + +func TestDirPrefersPotokConfigDir(t *testing.T) { + t.Setenv("POTOK_CONFIG_DIR", "/custom/potok") + t.Setenv("XDG_CONFIG_HOME", "/xdg") + + got, err := Dir() + if err != nil { + t.Fatalf("Dir() = %v", err) + } + if got != "/custom/potok" { + t.Errorf("Dir() = %q, want /custom/potok", got) + } +} + +func TestDirFallsBackToXDG(t *testing.T) { + t.Setenv("POTOK_CONFIG_DIR", "") + t.Setenv("XDG_CONFIG_HOME", "/xdg") + + got, err := Dir() + if err != nil { + t.Fatalf("Dir() = %v", err) + } + if want := filepath.Join("/xdg", "potok"); got != want { + t.Errorf("Dir() = %q, want %q", got, want) + } +}