diff --git a/.golangci.yml b/.golangci.yml index bfe9ac0..b7c1585 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -54,6 +54,7 @@ linters-settings: - rangeValCopy - stringXbytes - unnamedResult + - ifElseChain goconst: min-len: 3 @@ -123,6 +124,7 @@ issues: - goconst - errcheck - gocyclo + - unparam # Exclude known false positives - path: pkg/crypto/crypto.go @@ -187,5 +189,10 @@ issues: linters: - unparam + # sqlclosecheck: folder.go uses manual Close() in loops where defer isn't appropriate + - path: pkg/vault/folder\.go + linters: + - sqlclosecheck + max-issues-per-linter: 50 max-same-issues: 10 diff --git a/cmd/secretctl/folder.go b/cmd/secretctl/folder.go new file mode 100644 index 0000000..63dcec4 --- /dev/null +++ b/cmd/secretctl/folder.go @@ -0,0 +1,466 @@ +// Package main provides the secretctl CLI application. +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/forest6511/secretctl/pkg/vault" +) + +// Folder command flags +var ( + folderParent string + folderIcon string + folderColor string + folderJSON bool + folderRecursive bool +) + +// folderCmd is the parent command for folder operations. +var folderCmd = &cobra.Command{ + Use: "folder", + Short: "Folder operations", + Long: `Manage folders for organizing secrets. + +Folders provide hierarchical organization for secrets. Use path syntax +(e.g., "Work/APIs") to specify nested folders.`, +} + +// folderCreateCmd creates a new folder. +var folderCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new folder", + Long: `Create a new folder for organizing secrets. + +Examples: + secretctl folder create "Work" + secretctl folder create "APIs" --parent="Work" + secretctl folder create "Production" --parent="Work/APIs" --icon="🚀"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + name := args[0] + + // Resolve parent folder if specified + var parentID *string + if folderParent != "" { + parent, err := v.GetFolderByPath(folderParent) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("parent folder not found: %s", folderParent) + } + return fmt.Errorf("failed to find parent folder: %w", err) + } + parentID = &parent.ID + } + + folder := &vault.Folder{ + ID: uuid.New().String(), + Name: name, + ParentID: parentID, + Icon: folderIcon, + Color: folderColor, + } + + if err := v.CreateFolder(folder); err != nil { + if errors.Is(err, vault.ErrFolderExists) { + return fmt.Errorf("folder already exists: %s", name) + } + if errors.Is(err, vault.ErrFolderNameInvalid) || errors.Is(err, vault.ErrFolderNameSlash) || + errors.Is(err, vault.ErrFolderNameTooLong) || errors.Is(err, vault.ErrFolderNameTooShort) { + return fmt.Errorf("invalid folder name: %s", err) + } + return fmt.Errorf("failed to create folder: %w", err) + } + + if folderJSON { + output, _ := json.MarshalIndent(folder, "", " ") + fmt.Println(string(output)) + } else { + path := name + if folderParent != "" { + path = folderParent + "/" + name + } + fmt.Printf("Created folder: %s (ID: %s)\n", path, folder.ID) + } + + return nil + }, +} + +// folderListCmd lists all folders. +var folderListCmd = &cobra.Command{ + Use: "list [parent-path]", + Short: "List folders", + Long: `List folders in the vault. + +Without arguments, lists all root folders. +With a path argument, lists children of that folder. + +Examples: + secretctl folder list + secretctl folder list "Work"`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + var parentID *string + if len(args) > 0 { + parent, err := v.GetFolderByPath(args[0]) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", args[0]) + } + return fmt.Errorf("failed to find folder: %w", err) + } + parentID = &parent.ID + } + + folders, err := v.ListFolders(parentID) + if err != nil { + return fmt.Errorf("failed to list folders: %w", err) + } + + if len(folders) == 0 { + if !folderJSON { + fmt.Println("No folders found.") + } else { + fmt.Println("[]") + } + return nil + } + + if folderJSON { + output, _ := json.MarshalIndent(folders, "", " ") + fmt.Println(string(output)) + } else { + for _, f := range folders { + icon := "" + if f.Icon != "" { + icon = f.Icon + " " + } + stats := "" + if f.SecretCount > 0 { + stats = fmt.Sprintf(" (%d secrets)", f.SecretCount) + } + fmt.Printf("%s%s%s\n", icon, f.Path, stats) + } + } + + return nil + }, +} + +// folderDeleteCmd deletes a folder. +var folderDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a folder", + Long: `Delete a folder from the vault. + +The folder must be empty (no secrets or subfolders) unless --force is specified. +With --force, secrets are moved to unfiled and subfolders are deleted recursively. + +Examples: + secretctl folder delete "Work/APIs" + secretctl folder delete "Work" --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + folderPath := args[0] + + folder, err := v.GetFolderByPath(folderPath) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", folderPath) + } + return fmt.Errorf("failed to find folder: %w", err) + } + + if err := v.DeleteFolder(folder.ID, folderRecursive); err != nil { + if errors.Is(err, vault.ErrFolderHasChildren) || errors.Is(err, vault.ErrFolderHasSecrets) { + return fmt.Errorf("folder is not empty (use --force to delete with contents)") + } + return fmt.Errorf("failed to delete folder: %w", err) + } + + fmt.Printf("Deleted folder: %s\n", folderPath) + return nil + }, +} + +// folderRenameCmd renames a folder. +var folderRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Rename a folder", + Long: `Rename a folder in the vault. + +Examples: + secretctl folder rename "Work" "Professional" + secretctl folder rename "Work/APIs" "Services"`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + folderPath := args[0] + newName := args[1] + + folder, err := v.GetFolderByPath(folderPath) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", folderPath) + } + return fmt.Errorf("failed to find folder: %w", err) + } + + // Update the folder name + folder.Name = newName + if err := v.UpdateFolder(folder); err != nil { + if errors.Is(err, vault.ErrFolderExists) { + return fmt.Errorf("a folder with name %q already exists in the same location", newName) + } + if errors.Is(err, vault.ErrFolderNameInvalid) || errors.Is(err, vault.ErrFolderNameSlash) || + errors.Is(err, vault.ErrFolderNameTooLong) || errors.Is(err, vault.ErrFolderNameTooShort) { + return fmt.Errorf("invalid folder name: %s", err) + } + return fmt.Errorf("failed to rename folder: %w", err) + } + + fmt.Printf("Renamed folder: %s -> %s\n", folderPath, newName) + return nil + }, +} + +// folderMoveCmd moves a folder to a new parent. +var folderMoveCmd = &cobra.Command{ + Use: "move ", + Short: "Move a folder to a new location", + Long: `Move a folder to a new parent folder. + +Use empty string "" or "/" to move to root level. + +Examples: + secretctl folder move "Work/APIs" "Personal" + secretctl folder move "Work/APIs" ""`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + folderPath := args[0] + newParentPath := args[1] + + folder, err := v.GetFolderByPath(folderPath) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", folderPath) + } + return fmt.Errorf("failed to find folder: %w", err) + } + + // Resolve new parent + var newParentID *string + if newParentPath != "" && newParentPath != "/" { + newParent, err := v.GetFolderByPath(newParentPath) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("target folder not found: %s", newParentPath) + } + return fmt.Errorf("failed to find target folder: %w", err) + } + newParentID = &newParent.ID + } + + // Update the folder parent + folder.ParentID = newParentID + if err := v.UpdateFolder(folder); err != nil { + if errors.Is(err, vault.ErrFolderExists) { + return fmt.Errorf("a folder with name %q already exists in the target location", folder.Name) + } + if errors.Is(err, vault.ErrFolderCircular) { + return errors.New("cannot move folder into its own subtree") + } + return fmt.Errorf("failed to move folder: %w", err) + } + + if newParentPath == "" || newParentPath == "/" { + fmt.Printf("Moved folder: %s -> / (root)\n", folderPath) + } else { + fmt.Printf("Moved folder: %s -> %s/%s\n", folderPath, newParentPath, folder.Name) + } + return nil + }, +} + +// folderInfoCmd shows detailed information about a folder. +var folderInfoCmd = &cobra.Command{ + Use: "info ", + Short: "Show folder information", + Long: `Show detailed information about a folder. + +Examples: + secretctl folder info "Work" + secretctl folder info "Work/APIs" --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := ensureUnlocked(); err != nil { + return err + } + defer v.Lock() + + folderPath := args[0] + + folder, err := v.GetFolderByPath(folderPath) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", folderPath) + } + return fmt.Errorf("failed to find folder: %w", err) + } + + // Get folder stats by listing this folder's parent and finding it + var parentID *string + if folder.ParentID != nil { + parentID = folder.ParentID + } + allFolders, err := v.ListFolders(parentID) + if err != nil { + return fmt.Errorf("failed to get folder stats: %w", err) + } + + // Find the matching folder in the list to get stats + var stats *vault.FolderWithStats + for _, f := range allFolders { + if f.ID == folder.ID { + stats = f + break + } + } + + if stats == nil { + // Fallback: create stats without counts + folder.Path = folderPath + stats = &vault.FolderWithStats{ + Folder: *folder, + } + } + + if folderJSON { + output, _ := json.MarshalIndent(stats, "", " ") + fmt.Println(string(output)) + } else { + fmt.Printf("Name: %s\n", stats.Name) + fmt.Printf("Path: %s\n", stats.Path) + fmt.Printf("ID: %s\n", stats.ID) + if stats.Icon != "" { + fmt.Printf("Icon: %s\n", stats.Icon) + } + if stats.Color != "" { + fmt.Printf("Color: %s\n", stats.Color) + } + fmt.Printf("Secrets: %d\n", stats.SecretCount) + fmt.Printf("Subfolders: %d\n", stats.SubfolderCount) + fmt.Printf("Created: %s\n", stats.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", stats.UpdatedAt.Format("2006-01-02 15:04:05")) + } + + return nil + }, +} + +// Helper function to parse folder path from flags +func resolveFolderFromFlags() (*string, error) { + // Check if --folder-id is specified (takes precedence) + if setFolderID != "" { + return &setFolderID, nil + } + + // Check if --folder is specified + if setFolder != "" { + folder, err := v.GetFolderByPath(setFolder) + if err != nil { + if errors.Is(err, vault.ErrFolderNotFound) || errors.Is(err, vault.ErrFolderPathNotFound) { + return nil, fmt.Errorf("folder not found: %s", setFolder) + } + return nil, fmt.Errorf("failed to find folder: %w", err) + } + return &folder.ID, nil + } + + return nil, nil +} + +// formatFolderPath formats a folder path for display +func formatFolderPath(folderID *string) string { + if folderID == nil { + return "(unfiled)" + } + + folder, err := v.GetFolder(*folderID) + if err != nil { + return fmt.Sprintf("(folder: %s)", (*folderID)[:8]) + } + + // Build path + var parts []string + current := folder + for current != nil { + parts = append([]string{current.Name}, parts...) + if current.ParentID == nil { + break + } + parent, err := v.GetFolder(*current.ParentID) + if err != nil { + break + } + current = parent + } + + return strings.Join(parts, "/") +} + +func init() { + // Add folder subcommands + folderCmd.AddCommand(folderCreateCmd) + folderCmd.AddCommand(folderListCmd) + folderCmd.AddCommand(folderDeleteCmd) + folderCmd.AddCommand(folderRenameCmd) + folderCmd.AddCommand(folderMoveCmd) + folderCmd.AddCommand(folderInfoCmd) + + // Create command flags + folderCreateCmd.Flags().StringVar(&folderParent, "parent", "", "Parent folder path") + folderCreateCmd.Flags().StringVar(&folderIcon, "icon", "", "Folder icon (emoji)") + folderCreateCmd.Flags().StringVar(&folderColor, "color", "", "Folder color (hex code)") + folderCreateCmd.Flags().BoolVar(&folderJSON, "json", false, "Output as JSON") + + // List command flags + folderListCmd.Flags().BoolVar(&folderJSON, "json", false, "Output as JSON") + + // Delete command flags + folderDeleteCmd.Flags().BoolVarP(&folderRecursive, "force", "f", false, "Force delete folder and all contents") + + // Info command flags + folderInfoCmd.Flags().BoolVar(&folderJSON, "json", false, "Output as JSON") +} diff --git a/cmd/secretctl/root.go b/cmd/secretctl/root.go index 45626f5..b68f6f8 100644 --- a/cmd/secretctl/root.go +++ b/cmd/secretctl/root.go @@ -3,6 +3,7 @@ package main import ( "bufio" "encoding/json" + "errors" "fmt" "io" "os" @@ -56,6 +57,10 @@ var ( setBindings []string // --binding ENV=field (can be repeated) setTemplate string // --template name + // Folder support (Phase 2c-X2) + setFolder string // --folder path + setFolderID string // --folder-id UUID // --template name + // Get field support getField string // --field name (get specific field) getShowFields bool // --fields (list all fields) @@ -65,6 +70,10 @@ var ( var ( listTag string listExpiring string + + // Folder support (Phase 2c-X2) + listFolder string // --folder path + listFolderID string // --folder-id UUID ) // Metadata flags for get command @@ -102,6 +111,7 @@ func init() { rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(auditCmd) rootCmd.AddCommand(passwordCmd) + rootCmd.AddCommand(folderCmd) // Add metadata flags to set command setCmd.Flags().StringVar(&setNotes, "notes", "", "Add notes to the secret") @@ -114,10 +124,18 @@ func init() { setCmd.Flags().StringArrayVar(&setBindings, "binding", nil, "Set env binding (ENV_VAR=field, can be repeated)") setCmd.Flags().StringVar(&setTemplate, "template", "", "Use template (login, database, api, ssh)") + // Folder flags for set command (Phase 2c-X2) + setCmd.Flags().StringVar(&setFolder, "folder", "", "Folder path (e.g., Work/APIs)") + setCmd.Flags().StringVar(&setFolderID, "folder-id", "", "Folder ID (UUID)") + // Add metadata flags to list command listCmd.Flags().StringVar(&listTag, "tag", "", "Filter by tag") listCmd.Flags().StringVar(&listExpiring, "expiring", "", "Show secrets expiring within duration (e.g., 7d)") + // Folder flags for list command (Phase 2c-X2) + listCmd.Flags().StringVar(&listFolder, "folder", "", "Filter by folder path") + listCmd.Flags().StringVar(&listFolderID, "folder-id", "", "Filter by folder ID (UUID)") + // Add metadata flags to get command getCmd.Flags().BoolVar(&getShowMetadata, "show-metadata", false, "Show metadata with the secret") getCmd.Flags().StringVar(&getField, "field", "", "Get specific field value") @@ -286,6 +304,13 @@ Available templates: login, database, api, ssh`, entry.ExpiresAt = &expiresAt } + // Add folder (Phase 2c-X2) + folderID, err := resolveFolderFromFlags() + if err != nil { + return err + } + entry.FolderID = folderID + // 3. Save secret if err := v.SetSecret(key, entry); err != nil { return fmt.Errorf("failed to set secret: %w", err) @@ -590,7 +615,26 @@ var listCmd = &cobra.Command{ var entries []*vault.SecretEntry var err error - if listTag != "" { + // Handle folder filter (Phase 2c-X2) + if listFolder != "" || listFolderID != "" { + var folderID string + if listFolderID != "" { + folderID = listFolderID + } else { + folder, folderErr := v.GetFolderByPath(listFolder) + if folderErr != nil { + if errors.Is(folderErr, vault.ErrFolderNotFound) || errors.Is(folderErr, vault.ErrFolderPathNotFound) { + return fmt.Errorf("folder not found: %s", listFolder) + } + return fmt.Errorf("failed to find folder: %w", folderErr) + } + folderID = folder.ID + } + entries, err = v.ListSecretsInFolder(&folderID, false) + if err != nil { + return fmt.Errorf("failed to list secrets in folder: %w", err) + } + } else if listTag != "" { // Filter by tag entries, err = v.ListSecretsByTag(listTag) if err != nil { @@ -639,6 +683,9 @@ var listCmd = &cobra.Command{ if entry.ExpiresAt != nil { line += fmt.Sprintf(" (expires: %s)", entry.ExpiresAt.Format("2006-01-02")) } + if entry.FolderID != nil { + line += fmt.Sprintf(" {%s}", formatFolderPath(entry.FolderID)) + } fmt.Println(line) } return nil diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 53482f4..9e3262d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -153,6 +153,25 @@ func (s *Server) registerTools() { Name: "security_score", Description: "Get the security health score of your vault including password strength, duplicate detection, and expiration status. Returns a score from 0-100 with issue details and suggestions.", }, s.handleSecurityScore) + + // Phase 2c-X2: Folder MCP tools + // folder_list - List folders with metadata + mcp.AddTool(s.server, &mcp.Tool{ + Name: "folder_list", + Description: "List all folders with metadata including secret count and subfolder count. Use parent_id to list children of a specific folder.", + }, s.handleFolderList) + + // folder_create - Create a new folder + mcp.AddTool(s.server, &mcp.Tool{ + Name: "folder_create", + Description: "Create a new folder for organizing secrets. Folder names cannot contain '/'.", + }, s.handleFolderCreate) + + // folder_move_secret - Move a secret to a folder + mcp.AddTool(s.server, &mcp.Tool{ + Name: "folder_move_secret", + Description: "Move a secret to a different folder. Set folder_id to null to unfile the secret.", + }, s.handleFolderMoveSecret) } // Run starts the MCP server using stdio transport. diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 1fab7ca..0a0a1e6 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -3,6 +3,7 @@ package mcp import ( "bytes" "context" + "crypto/rand" "encoding/base64" "encoding/hex" "errors" @@ -29,6 +30,7 @@ import ( type SecretListInput struct { Tag string `json:"tag,omitempty"` ExpiringWithin string `json:"expiring_within,omitempty"` + FolderID string `json:"folder_id,omitempty"` // Phase 2c-X2: Filter by folder } // SecretListOutput represents output for secret_list tool. @@ -46,6 +48,8 @@ type SecretInfo struct { HasURL bool `json:"has_url"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + FolderID string `json:"folder_id,omitempty"` // Phase 2c-X2: Folder UUID + FolderPath string `json:"folder_path,omitempty"` // Phase 2c-X2: Computed path for display } // SecretExistsInput represents input for secret_exists tool. @@ -193,6 +197,13 @@ func (s *Server) handleSecretList(_ context.Context, _ *mcp.CallToolRequest, inp var err error switch { + case input.FolderID != "": + // Filter by folder (Phase 2c-X2) + entries, err = s.vault.ListSecretsInFolder(&input.FolderID, false) + if err != nil { + _ = s.vault.Audit().LogError(audit.OpSecretList, audit.SourceMCP, "", "LIST_FAILED", err.Error()) + return nil, SecretListOutput{}, fmt.Errorf("failed to list secrets in folder: %w", err) + } case input.Tag != "": // Filter by tag entries, err = s.vault.ListSecretsByTag(input.Tag) @@ -240,6 +251,14 @@ func (s *Server) handleSecretList(_ context.Context, _ *mcp.CallToolRequest, inp if entry.ExpiresAt != nil { info.ExpiresAt = entry.ExpiresAt.Format(time.RFC3339) } + // Phase 2c-X2: Include folder information + if entry.FolderID != nil { + info.FolderID = *entry.FolderID + folder, folderErr := s.vault.GetFolder(*entry.FolderID) + if folderErr == nil { + info.FolderPath = s.computeFolderPath(folder) + } + } output.Secrets = append(output.Secrets, info) } @@ -1358,3 +1377,195 @@ func (s *Server) handleSecurityScore(_ context.Context, _ *mcp.CallToolRequest, return nil, output, nil } + +// ======================================== +// Phase 2c-X2: Folder MCP Tools +// ======================================== + +// FolderListInput for folder_list tool. +type FolderListInput struct { + ParentID string `json:"parent_id,omitempty"` // Filter by parent folder (nil for root folders) +} + +// FolderListOutput for folder_list tool. +type FolderListOutput struct { + Folders []FolderInfo `json:"folders"` +} + +// FolderInfo represents folder metadata for MCP responses. +type FolderInfo struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID string `json:"parent_id,omitempty"` + Path string `json:"path"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + SortOrder int `json:"sort_order"` + SecretCount int `json:"secret_count"` + SubfolderCount int `json:"subfolder_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// FolderCreateInput for folder_create tool. +type FolderCreateInput struct { + Name string `json:"name"` // Required, no "/" allowed + ParentID string `json:"parent_id,omitempty"` // Optional parent folder UUID + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` +} + +// FolderCreateOutput for folder_create tool. +type FolderCreateOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// FolderMoveSecretInput for folder_move_secret tool. +type FolderMoveSecretInput struct { + SecretKey string `json:"secret_key"` // Secret key to move + FolderID *string `json:"folder_id"` // Target folder UUID (null for unfiled) +} + +// FolderMoveSecretOutput for folder_move_secret tool. +type FolderMoveSecretOutput struct { + SecretKey string `json:"secret_key"` + FolderID string `json:"folder_id,omitempty"` + FolderPath string `json:"folder_path,omitempty"` +} + +// handleFolderList handles the folder_list tool call. +func (s *Server) handleFolderList(_ context.Context, _ *mcp.CallToolRequest, input FolderListInput) (*mcp.CallToolResult, FolderListOutput, error) { + var parentID *string + if input.ParentID != "" { + parentID = &input.ParentID + } + + folders, err := s.vault.ListFolders(parentID) + if err != nil { + return nil, FolderListOutput{}, fmt.Errorf("failed to list folders: %w", err) + } + + output := FolderListOutput{ + Folders: make([]FolderInfo, 0, len(folders)), + } + + for _, f := range folders { + info := FolderInfo{ + ID: f.ID, + Name: f.Name, + Path: f.Path, + Icon: f.Icon, + Color: f.Color, + SortOrder: f.SortOrder, + SecretCount: f.SecretCount, + SubfolderCount: f.SubfolderCount, + CreatedAt: f.CreatedAt.Format(time.RFC3339), + UpdatedAt: f.UpdatedAt.Format(time.RFC3339), + } + if f.ParentID != nil { + info.ParentID = *f.ParentID + } + output.Folders = append(output.Folders, info) + } + + return nil, output, nil +} + +// handleFolderCreate handles the folder_create tool call. +func (s *Server) handleFolderCreate(_ context.Context, _ *mcp.CallToolRequest, input FolderCreateInput) (*mcp.CallToolResult, FolderCreateOutput, error) { + if input.Name == "" { + return nil, FolderCreateOutput{}, errors.New("name is required") + } + + var parentID *string + if input.ParentID != "" { + parentID = &input.ParentID + } + + folder := &vault.Folder{ + ID: generateUUID(), + Name: input.Name, + ParentID: parentID, + Icon: input.Icon, + Color: input.Color, + } + + if err := s.vault.CreateFolder(folder); err != nil { + return nil, FolderCreateOutput{}, fmt.Errorf("failed to create folder: %w", err) + } + + // Compute path for response + path := input.Name + if parentID != nil { + parent, err := s.vault.GetFolder(*parentID) + if err == nil { + path = s.computeFolderPath(parent) + "/" + input.Name + } + } + + return nil, FolderCreateOutput{ + ID: folder.ID, + Name: folder.Name, + Path: path, + }, nil +} + +// handleFolderMoveSecret handles the folder_move_secret tool call. +func (s *Server) handleFolderMoveSecret(_ context.Context, _ *mcp.CallToolRequest, input FolderMoveSecretInput) (*mcp.CallToolResult, FolderMoveSecretOutput, error) { + if input.SecretKey == "" { + return nil, FolderMoveSecretOutput{}, errors.New("secret_key is required") + } + + if err := s.vault.MoveSecretToFolder(input.SecretKey, input.FolderID); err != nil { + return nil, FolderMoveSecretOutput{}, fmt.Errorf("failed to move secret: %w", err) + } + + output := FolderMoveSecretOutput{ + SecretKey: input.SecretKey, + } + + if input.FolderID != nil { + output.FolderID = *input.FolderID + folder, err := s.vault.GetFolder(*input.FolderID) + if err == nil { + output.FolderPath = s.computeFolderPath(folder) + } + } + + return nil, output, nil +} + +// computeFolderPath computes the full path for a folder. +func (s *Server) computeFolderPath(folder *vault.Folder) string { + if folder == nil { + return "" + } + + var parts []string + current := folder + for current != nil { + parts = append([]string{current.Name}, parts...) + if current.ParentID == nil { + break + } + parent, err := s.vault.GetFolder(*current.ParentID) + if err != nil { + break + } + current = parent + } + + return strings.Join(parts, "/") +} + +// generateUUID generates a new UUID v4 string. +func generateUUID() string { + // Simple UUID v4 generation using crypto/rand + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 // Version 4 + b[8] = (b[8] & 0x3f) | 0x80 // Variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/pkg/security/score.go b/pkg/security/score.go index a4e4b11..ddd1dca 100644 --- a/pkg/security/score.go +++ b/pkg/security/score.go @@ -298,7 +298,6 @@ func (c *Calculator) calculateExpirationScore(secrets []*vault.SecretEntry, incl secretsWithExpiration++ expiresAt := *entry.ExpiresAt - //nolint:gocritic // if-else chain is clearer for time comparisons if expiresAt.Before(now) { // Already expired issue := SecurityIssue{ diff --git a/pkg/vault/folder.go b/pkg/vault/folder.go new file mode 100644 index 0000000..d8b6a19 --- /dev/null +++ b/pkg/vault/folder.go @@ -0,0 +1,730 @@ +// Package vault provides secure secret storage with AES-256-GCM encryption. +package vault + +import ( + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" +) + +// Folder validation constants per ADR-007 +const ( + MaxFolderNameLength = 128 + MinFolderNameLength = 1 + MaxFolderDepth = 10 // Maximum nesting depth to prevent infinite loops +) + +// Folder errors +var ( + ErrFolderNotFound = errors.New("vault: folder not found") + ErrFolderNameInvalid = errors.New("vault: folder name is invalid") + ErrFolderNameTooLong = errors.New("vault: folder name is too long") + ErrFolderNameTooShort = errors.New("vault: folder name is too short") + ErrFolderNameSlash = errors.New("vault: folder name cannot contain '/'") + ErrFolderExists = errors.New("vault: folder already exists with this name") + ErrFolderHasChildren = errors.New("vault: folder has children (use --force to move to unfiled)") + ErrFolderHasSecrets = errors.New("vault: folder contains secrets (use --force to move to unfiled)") + ErrFolderCircular = errors.New("vault: circular parent reference detected") + ErrFolderDepthExceeded = errors.New("vault: maximum folder depth exceeded") + ErrFolderPathAmbiguous = errors.New("vault: folder path is ambiguous") + ErrFolderPathNotFound = errors.New("vault: folder path not found") + ErrFolderSelfParent = errors.New("vault: folder cannot be its own parent") + ErrFolderIDInvalid = errors.New("vault: folder ID is invalid") +) + +// Folder represents a folder for organizing secrets. +// Per ADR-007: FolderId + Folder Table design. +type Folder struct { + ID string `json:"id"` // UUID + Name string `json:"name"` // Display name (no "/" allowed) + ParentID *string `json:"parent_id,omitempty"` // NULL for root, UUID for nested + Icon string `json:"icon,omitempty"` // Emoji or icon name + Color string `json:"color,omitempty"` // Hex color code + SortOrder int `json:"sort_order"` // For manual ordering + Path string `json:"path,omitempty"` // Computed: "Work/APIs" (not stored) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FolderWithStats extends Folder with computed statistics for listing. +type FolderWithStats struct { + Folder + SecretCount int `json:"secret_count"` // Number of secrets directly in folder + SubfolderCount int `json:"subfolder_count"` // Number of immediate subfolders +} + +// colorHexRegex validates hex color codes +var colorHexRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +// validateFolderName validates a folder name per ADR-007. +func validateFolderName(name string) error { + if len(name) < MinFolderNameLength { + return ErrFolderNameTooShort + } + if len(name) > MaxFolderNameLength { + return ErrFolderNameTooLong + } + if strings.Contains(name, "/") { + return ErrFolderNameSlash + } + // Allow most printable characters except "/" + // The CHECK constraint in SQL enforces no "/" + name = strings.TrimSpace(name) + if name == "" { + return ErrFolderNameTooShort + } + return nil +} + +// validateFolderColor validates a hex color code. +func validateFolderColor(color string) error { + if color == "" { + return nil // Empty is allowed + } + if !colorHexRegex.MatchString(color) { + return fmt.Errorf("vault: invalid color format (expected #RRGGBB): %s", color) + } + return nil +} + +// CreateFolder creates a new folder. +// Per ADR-007: PRAGMA foreign_keys = ON is set on connection. +func (v *Vault) CreateFolder(folder *Folder) error { + v.mu.Lock() + defer v.mu.Unlock() + + if v.dek == nil { + return ErrVaultLocked + } + + // Validate folder name + if err := validateFolderName(folder.Name); err != nil { + return err + } + + // Validate color if provided + if err := validateFolderColor(folder.Color); err != nil { + return err + } + + // Generate UUID if not provided + if folder.ID == "" { + folder.ID = uuid.New().String() + } + + // Validate parent exists if specified + if folder.ParentID != nil && *folder.ParentID != "" { + var parentExists int + err := v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE id = ?", *folder.ParentID).Scan(&parentExists) + if err != nil { + return fmt.Errorf("vault: failed to check parent folder: %w", err) + } + if parentExists == 0 { + return fmt.Errorf("%w: parent_id %s", ErrFolderNotFound, *folder.ParentID) + } + + // Check depth doesn't exceed maximum + depth, err := v.getFolderDepth(*folder.ParentID) + if err != nil { + return err + } + if depth >= MaxFolderDepth-1 { + return ErrFolderDepthExceeded + } + } + + // Insert folder + now := time.Now().UTC() + _, err := v.db.Exec(` + INSERT INTO folders (id, name, parent_id, icon, color, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, folder.ID, folder.Name, folder.ParentID, folder.Icon, folder.Color, folder.SortOrder, now, now) + + if err != nil { + // Check for unique constraint violation + if strings.Contains(err.Error(), "UNIQUE constraint") { + return ErrFolderExists + } + return fmt.Errorf("vault: failed to create folder: %w", err) + } + + folder.CreatedAt = now + folder.UpdatedAt = now + + return nil +} + +// GetFolder retrieves a folder by ID. +func (v *Vault) GetFolder(id string) (*Folder, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.dek == nil { + return nil, ErrVaultLocked + } + + folder := &Folder{} + var parentID sql.NullString + var icon, color sql.NullString + + err := v.db.QueryRow(` + SELECT id, name, parent_id, icon, color, sort_order, created_at, updated_at + FROM folders WHERE id = ? + `, id).Scan( + &folder.ID, &folder.Name, &parentID, &icon, &color, + &folder.SortOrder, &folder.CreatedAt, &folder.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, ErrFolderNotFound + } + if err != nil { + return nil, fmt.Errorf("vault: failed to get folder: %w", err) + } + + if parentID.Valid { + folder.ParentID = &parentID.String + } + if icon.Valid { + folder.Icon = icon.String + } + if color.Valid { + folder.Color = color.String + } + + // Compute path + folder.Path, _ = v.computeFolderPathLocked(folder.ID) + + return folder, nil +} + +// GetFolderByPath finds a folder by its path (e.g., "Work/APIs"). +// Returns ErrFolderPathAmbiguous if multiple folders match. +func (v *Vault) GetFolderByPath(path string) (*Folder, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.dek == nil { + return nil, ErrVaultLocked + } + + return v.getFolderByPathLocked(path) +} + +// getFolderByPathLocked finds a folder by path without locking. +func (v *Vault) getFolderByPathLocked(path string) (*Folder, error) { + if path == "" { + return nil, ErrFolderPathNotFound + } + + parts := strings.Split(path, "/") + var currentParentID *string + + for i, name := range parts { + name = strings.TrimSpace(name) + if name == "" { + continue + } + + // Find folder with this name and parent + var folders []Folder + var query string + var args []any + + if currentParentID == nil { + query = "SELECT id, name, parent_id FROM folders WHERE name = ? COLLATE NOCASE AND parent_id IS NULL" + args = []any{name} + } else { + query = "SELECT id, name, parent_id FROM folders WHERE name = ? COLLATE NOCASE AND parent_id = ?" + args = []any{name, *currentParentID} + } + + rows, err := v.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("vault: failed to query folders: %w", err) + } + + for rows.Next() { + var f Folder + var parentID sql.NullString + if err := rows.Scan(&f.ID, &f.Name, &parentID); err != nil { + rows.Close() + return nil, fmt.Errorf("vault: failed to scan folder: %w", err) + } + if parentID.Valid { + f.ParentID = &parentID.String + } + folders = append(folders, f) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("vault: error iterating folders: %w", err) + } + rows.Close() + + if len(folders) == 0 { + return nil, fmt.Errorf("%w: %s", ErrFolderPathNotFound, path) + } + if len(folders) > 1 { + return nil, fmt.Errorf("%w: multiple folders named '%s' at this level", ErrFolderPathAmbiguous, name) + } + + // Move to next level + if i == len(parts)-1 { + // Last part - return the full folder + return v.GetFolder(folders[0].ID) + } + currentParentID = &folders[0].ID + } + + return nil, ErrFolderPathNotFound +} + +// ListFolders returns all folders with optional parent filter. +// If parentID is nil, returns all folders. If parentID is empty string pointer, +// returns root folders only. Otherwise returns children of specified parent. +func (v *Vault) ListFolders(parentID *string) ([]*FolderWithStats, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.dek == nil { + return nil, ErrVaultLocked + } + + var query string + var args []any + + if parentID == nil { + // All folders + query = ` + SELECT f.id, f.name, f.parent_id, f.icon, f.color, f.sort_order, f.created_at, f.updated_at, + (SELECT COUNT(*) FROM secrets s WHERE s.folder_id = f.id) as secret_count, + (SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) as subfolder_count + FROM folders f + ORDER BY f.parent_id NULLS FIRST, f.sort_order, f.name COLLATE NOCASE + ` + } else if *parentID == "" { + // Root folders only + query = ` + SELECT f.id, f.name, f.parent_id, f.icon, f.color, f.sort_order, f.created_at, f.updated_at, + (SELECT COUNT(*) FROM secrets s WHERE s.folder_id = f.id) as secret_count, + (SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) as subfolder_count + FROM folders f + WHERE f.parent_id IS NULL + ORDER BY f.sort_order, f.name COLLATE NOCASE + ` + } else { + // Children of specific parent + query = ` + SELECT f.id, f.name, f.parent_id, f.icon, f.color, f.sort_order, f.created_at, f.updated_at, + (SELECT COUNT(*) FROM secrets s WHERE s.folder_id = f.id) as secret_count, + (SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) as subfolder_count + FROM folders f + WHERE f.parent_id = ? + ORDER BY f.sort_order, f.name COLLATE NOCASE + ` + args = []any{*parentID} + } + + rows, err := v.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("vault: failed to list folders: %w", err) + } + defer rows.Close() + + // Collect folder data first, then iterate separately for computing paths + // (SQLite can deadlock if we query while iterating) + var folders []*FolderWithStats + for rows.Next() { + f := &FolderWithStats{} + var parentIDVal sql.NullString + var icon, color sql.NullString + + if err := rows.Scan( + &f.ID, &f.Name, &parentIDVal, &icon, &color, + &f.SortOrder, &f.CreatedAt, &f.UpdatedAt, + &f.SecretCount, &f.SubfolderCount, + ); err != nil { + return nil, fmt.Errorf("vault: failed to scan folder: %w", err) + } + + if parentIDVal.Valid { + f.ParentID = &parentIDVal.String + } + if icon.Valid { + f.Icon = icon.String + } + if color.Valid { + f.Color = color.String + } + + folders = append(folders, f) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("vault: error iterating folders: %w", err) + } + + // Now compute paths after iteration is complete + for _, f := range folders { + f.Path, _ = v.computeFolderPathLocked(f.ID) + } + + return folders, nil +} + +// UpdateFolder updates a folder's properties. +func (v *Vault) UpdateFolder(folder *Folder) error { + v.mu.Lock() + defer v.mu.Unlock() + + if v.dek == nil { + return ErrVaultLocked + } + + // Validate folder name + if err := validateFolderName(folder.Name); err != nil { + return err + } + + // Validate color if provided + if err := validateFolderColor(folder.Color); err != nil { + return err + } + + // Check folder exists + var exists int + err := v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE id = ?", folder.ID).Scan(&exists) + if err != nil { + return fmt.Errorf("vault: failed to check folder: %w", err) + } + if exists == 0 { + return ErrFolderNotFound + } + + // Prevent setting self as parent + if folder.ParentID != nil && *folder.ParentID == folder.ID { + return ErrFolderSelfParent + } + + // Validate parent exists if specified + if folder.ParentID != nil && *folder.ParentID != "" { + var parentExists int + err := v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE id = ?", *folder.ParentID).Scan(&parentExists) + if err != nil { + return fmt.Errorf("vault: failed to check parent folder: %w", err) + } + if parentExists == 0 { + return fmt.Errorf("%w: parent_id %s", ErrFolderNotFound, *folder.ParentID) + } + + // Check for circular reference + if v.wouldCreateCircularReference(folder.ID, *folder.ParentID) { + return ErrFolderCircular + } + } + + // Update folder + now := time.Now().UTC() + _, err = v.db.Exec(` + UPDATE folders + SET name = ?, parent_id = ?, icon = ?, color = ?, sort_order = ?, updated_at = ? + WHERE id = ? + `, folder.Name, folder.ParentID, folder.Icon, folder.Color, folder.SortOrder, now, folder.ID) + + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") { + return ErrFolderExists + } + return fmt.Errorf("vault: failed to update folder: %w", err) + } + + folder.UpdatedAt = now + return nil +} + +// DeleteFolder deletes a folder by ID. +// If force is false, returns error if folder has children or secrets. +// If force is true, moves children and secrets to unfiled (folder_id = NULL). +func (v *Vault) DeleteFolder(id string, force bool) error { + v.mu.Lock() + defer v.mu.Unlock() + + if v.dek == nil { + return ErrVaultLocked + } + + // Check folder exists + var exists int + err := v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE id = ?", id).Scan(&exists) + if err != nil { + return fmt.Errorf("vault: failed to check folder: %w", err) + } + if exists == 0 { + return ErrFolderNotFound + } + + // Check for children + var childCount int + err = v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE parent_id = ?", id).Scan(&childCount) + if err != nil { + return fmt.Errorf("vault: failed to count child folders: %w", err) + } + + // Check for secrets + var secretCount int + err = v.db.QueryRow("SELECT COUNT(*) FROM secrets WHERE folder_id = ?", id).Scan(&secretCount) + if err != nil { + return fmt.Errorf("vault: failed to count secrets: %w", err) + } + + if !force { + if childCount > 0 { + return fmt.Errorf("%w: %d subfolders", ErrFolderHasChildren, childCount) + } + if secretCount > 0 { + return fmt.Errorf("%w: %d secrets", ErrFolderHasSecrets, secretCount) + } + } + + // Begin transaction + tx, err := v.db.Begin() + if err != nil { + return fmt.Errorf("vault: failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // If force, move children to unfiled + if force { + // Move child folders to root (parent_id = NULL) + _, err = tx.Exec("UPDATE folders SET parent_id = NULL WHERE parent_id = ?", id) + if err != nil { + return fmt.Errorf("vault: failed to move child folders: %w", err) + } + + // Move secrets to unfiled (folder_id = NULL) + _, err = tx.Exec("UPDATE secrets SET folder_id = NULL WHERE folder_id = ?", id) + if err != nil { + return fmt.Errorf("vault: failed to move secrets: %w", err) + } + } + + // Delete folder + _, err = tx.Exec("DELETE FROM folders WHERE id = ?", id) + if err != nil { + return fmt.Errorf("vault: failed to delete folder: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("vault: failed to commit transaction: %w", err) + } + + return nil +} + +// MoveSecretToFolder moves a secret to a folder (or unfiled if folderID is nil). +func (v *Vault) MoveSecretToFolder(secretKey string, folderID *string) error { + v.mu.Lock() + defer v.mu.Unlock() + + if v.dek == nil { + return ErrVaultLocked + } + + // Validate folder exists if specified + if folderID != nil && *folderID != "" { + var exists int + err := v.db.QueryRow("SELECT COUNT(*) FROM folders WHERE id = ?", *folderID).Scan(&exists) + if err != nil { + return fmt.Errorf("vault: failed to check folder: %w", err) + } + if exists == 0 { + return ErrFolderNotFound + } + } + + // Compute key hash + keyHash := v.hashKey(secretKey) + + // Update secret + result, err := v.db.Exec("UPDATE secrets SET folder_id = ? WHERE key_hash = ?", folderID, keyHash) + if err != nil { + return fmt.Errorf("vault: failed to move secret: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("vault: failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + return ErrSecretNotFound + } + + return nil +} + +// ListSecretsInFolder returns secrets in a specific folder. +// If folderID is nil, returns unfiled secrets (folder_id IS NULL). +// If recursive is true, includes secrets from subfolders. +func (v *Vault) ListSecretsInFolder(folderID *string, recursive bool) ([]*SecretEntry, error) { + v.mu.RLock() + defer v.mu.RUnlock() + + if v.dek == nil { + return nil, ErrVaultLocked + } + + var query string + var args []any + + if folderID == nil || *folderID == "" { + // Unfiled secrets + query = ` + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at + FROM secrets + WHERE folder_id IS NULL + ORDER BY created_at + ` + } else if recursive { + // Secrets in folder and all subfolders (using recursive CTE) + query = ` + WITH RECURSIVE folder_tree AS ( + SELECT id FROM folders WHERE id = ? + UNION ALL + SELECT f.id FROM folders f + INNER JOIN folder_tree ft ON f.parent_id = ft.id + ) + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at + FROM secrets + WHERE folder_id IN (SELECT id FROM folder_tree) + ORDER BY created_at + ` + args = []any{*folderID} + } else { + // Secrets directly in folder + query = ` + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at + FROM secrets + WHERE folder_id = ? + ORDER BY created_at + ` + args = []any{*folderID} + } + + rows, err := v.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("vault: failed to query secrets: %w", err) + } + defer rows.Close() + + var secrets []*SecretEntry + for rows.Next() { + entry, err := v.scanSecretEntryRowWithMetadata(rows) + if err != nil { + return nil, err + } + secrets = append(secrets, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("vault: error iterating rows: %w", err) + } + + return secrets, nil +} + +// computeFolderPathLocked computes the full path for a folder. +// Must be called with lock held. +func (v *Vault) computeFolderPathLocked(folderID string) (string, error) { + var parts []string + currentID := folderID + visited := make(map[string]bool) + + for i := 0; i < MaxFolderDepth+1; i++ { + if visited[currentID] { + return "", ErrFolderCircular + } + visited[currentID] = true + + var name string + var parentID sql.NullString + err := v.db.QueryRow("SELECT name, parent_id FROM folders WHERE id = ?", currentID).Scan(&name, &parentID) + if err == sql.ErrNoRows { + break + } + if err != nil { + return "", fmt.Errorf("vault: failed to get folder: %w", err) + } + + parts = append([]string{name}, parts...) + + if !parentID.Valid || parentID.String == "" { + break + } + currentID = parentID.String + } + + return strings.Join(parts, "/"), nil +} + +// getFolderDepth returns the depth of a folder in the hierarchy (0 for root). +func (v *Vault) getFolderDepth(folderID string) (int, error) { + depth := 0 + currentID := folderID + visited := make(map[string]bool) + + for depth < MaxFolderDepth+1 { + if visited[currentID] { + return 0, ErrFolderCircular + } + visited[currentID] = true + + var parentID sql.NullString + err := v.db.QueryRow("SELECT parent_id FROM folders WHERE id = ?", currentID).Scan(&parentID) + if err == sql.ErrNoRows { + break + } + if err != nil { + return 0, fmt.Errorf("vault: failed to get folder: %w", err) + } + + if !parentID.Valid || parentID.String == "" { + break + } + depth++ + currentID = parentID.String + } + + return depth, nil +} + +// wouldCreateCircularReference checks if setting newParentID as parent of folderID +// would create a circular reference. +func (v *Vault) wouldCreateCircularReference(folderID, newParentID string) bool { + currentID := newParentID + visited := make(map[string]bool) + + for i := 0; i < MaxFolderDepth+1; i++ { + if currentID == folderID { + return true + } + if visited[currentID] { + return true // Already circular + } + visited[currentID] = true + + var parentID sql.NullString + err := v.db.QueryRow("SELECT parent_id FROM folders WHERE id = ?", currentID).Scan(&parentID) + if err != nil || !parentID.Valid || parentID.String == "" { + break + } + currentID = parentID.String + } + + return false +} diff --git a/pkg/vault/folder_test.go b/pkg/vault/folder_test.go new file mode 100644 index 0000000..8bf4835 --- /dev/null +++ b/pkg/vault/folder_test.go @@ -0,0 +1,529 @@ +package vault + +import ( + "os" + "path/filepath" + "testing" +) + +// setupTestVault creates a temporary vault for testing +func setupTestVaultForFolder(t *testing.T) (*Vault, string, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "vault-folder-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + vaultPath := filepath.Join(tmpDir, "vault") + v := New(vaultPath) + + // Initialize vault with password + if err := v.Init("testpassword123"); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to initialize vault: %v", err) + } + + // Unlock vault + if err := v.Unlock("testpassword123"); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("failed to unlock vault: %v", err) + } + + cleanup := func() { + v.Lock() + os.RemoveAll(tmpDir) + } + + return v, tmpDir, cleanup +} + +func TestCreateFolder(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + t.Run("create root folder", func(t *testing.T) { + folder := &Folder{ + ID: "test-id-1", + Name: "Work", + } + if err := v.CreateFolder(folder); err != nil { + t.Fatalf("failed to create folder: %v", err) + } + + // Verify folder exists + got, err := v.GetFolder("test-id-1") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if got.Name != "Work" { + t.Errorf("expected name 'Work', got '%s'", got.Name) + } + if got.ParentID != nil { + t.Errorf("expected nil parent, got '%v'", got.ParentID) + } + }) + + t.Run("create nested folder", func(t *testing.T) { + parentID := "test-id-1" + folder := &Folder{ + ID: "test-id-2", + Name: "APIs", + ParentID: &parentID, + } + if err := v.CreateFolder(folder); err != nil { + t.Fatalf("failed to create nested folder: %v", err) + } + + // Verify folder exists + got, err := v.GetFolder("test-id-2") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if got.Name != "APIs" { + t.Errorf("expected name 'APIs', got '%s'", got.Name) + } + if got.ParentID == nil || *got.ParentID != parentID { + t.Errorf("expected parent '%s', got '%v'", parentID, got.ParentID) + } + }) + + t.Run("create with icon and color", func(t *testing.T) { + folder := &Folder{ + ID: "test-id-3", + Name: "Personal", + Icon: "🏠", + Color: "#3B82F6", + } + if err := v.CreateFolder(folder); err != nil { + t.Fatalf("failed to create folder: %v", err) + } + + got, err := v.GetFolder("test-id-3") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if got.Icon != "🏠" { + t.Errorf("expected icon '🏠', got '%s'", got.Icon) + } + if got.Color != "#3B82F6" { + t.Errorf("expected color '#3B82F6', got '%s'", got.Color) + } + }) +} + +func TestCreateFolderValidation(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + t.Run("name with slash rejected", func(t *testing.T) { + folder := &Folder{ + ID: "test-slash", + Name: "Work/APIs", + } + err := v.CreateFolder(folder) + if err == nil { + t.Fatal("expected error for name with slash") + } + }) + + t.Run("empty name rejected", func(t *testing.T) { + folder := &Folder{ + ID: "test-empty", + Name: "", + } + err := v.CreateFolder(folder) + if err == nil { + t.Fatal("expected error for empty name") + } + }) + + t.Run("name too long rejected", func(t *testing.T) { + longName := make([]byte, 300) + for i := range longName { + longName[i] = 'a' + } + folder := &Folder{ + ID: "test-long", + Name: string(longName), + } + err := v.CreateFolder(folder) + if err == nil { + t.Fatal("expected error for name too long") + } + }) + + t.Run("duplicate name at same level rejected", func(t *testing.T) { + folder1 := &Folder{ID: "dup-1", Name: "Duplicate"} + if err := v.CreateFolder(folder1); err != nil { + t.Fatalf("failed to create first folder: %v", err) + } + + folder2 := &Folder{ID: "dup-2", Name: "Duplicate"} + err := v.CreateFolder(folder2) + if err == nil { + t.Fatal("expected error for duplicate name") + } + }) + + t.Run("same name at different levels allowed", func(t *testing.T) { + parentID := "dup-1" + folder := &Folder{ + ID: "dup-child", + Name: "Duplicate", + ParentID: &parentID, + } + if err := v.CreateFolder(folder); err != nil { + t.Fatalf("same name at different level should be allowed: %v", err) + } + }) +} + +func TestGetFolderByPath(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder hierarchy: Work/APIs/Production + work := &Folder{ID: "work-id", Name: "Work"} + if err := v.CreateFolder(work); err != nil { + t.Fatalf("failed to create Work: %v", err) + } + + workID := "work-id" + apis := &Folder{ID: "apis-id", Name: "APIs", ParentID: &workID} + if err := v.CreateFolder(apis); err != nil { + t.Fatalf("failed to create APIs: %v", err) + } + + apisID := "apis-id" + prod := &Folder{ID: "prod-id", Name: "Production", ParentID: &apisID} + if err := v.CreateFolder(prod); err != nil { + t.Fatalf("failed to create Production: %v", err) + } + + t.Run("get root folder", func(t *testing.T) { + folder, err := v.GetFolderByPath("Work") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if folder.ID != "work-id" { + t.Errorf("expected 'work-id', got '%s'", folder.ID) + } + }) + + t.Run("get nested folder", func(t *testing.T) { + folder, err := v.GetFolderByPath("Work/APIs") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if folder.ID != "apis-id" { + t.Errorf("expected 'apis-id', got '%s'", folder.ID) + } + }) + + t.Run("get deeply nested folder", func(t *testing.T) { + folder, err := v.GetFolderByPath("Work/APIs/Production") + if err != nil { + t.Fatalf("failed to get folder: %v", err) + } + if folder.ID != "prod-id" { + t.Errorf("expected 'prod-id', got '%s'", folder.ID) + } + }) + + t.Run("non-existent path returns error", func(t *testing.T) { + _, err := v.GetFolderByPath("NonExistent") + if err == nil { + t.Fatal("expected error for non-existent path") + } + }) + + t.Run("partial path returns error", func(t *testing.T) { + _, err := v.GetFolderByPath("Work/NonExistent") + if err == nil { + t.Fatal("expected error for partial path") + } + }) +} + +func TestListFolders(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder hierarchy + work := &Folder{ID: "work-id", Name: "Work"} + personal := &Folder{ID: "personal-id", Name: "Personal"} + v.CreateFolder(work) + v.CreateFolder(personal) + + workID := "work-id" + apis := &Folder{ID: "apis-id", Name: "APIs", ParentID: &workID} + v.CreateFolder(apis) + + t.Run("list root folders", func(t *testing.T) { + // Pass empty string pointer for root folders only + emptyStr := "" + folders, err := v.ListFolders(&emptyStr) + if err != nil { + t.Fatalf("failed to list folders: %v", err) + } + if len(folders) != 2 { + t.Errorf("expected 2 root folders, got %d", len(folders)) + } + }) + + t.Run("list children of folder", func(t *testing.T) { + folders, err := v.ListFolders(&workID) + if err != nil { + t.Fatalf("failed to list folders: %v", err) + } + if len(folders) != 1 { + t.Errorf("expected 1 child folder, got %d", len(folders)) + } + if folders[0].Name != "APIs" { + t.Errorf("expected 'APIs', got '%s'", folders[0].Name) + } + }) + + t.Run("list includes path", func(t *testing.T) { + folders, err := v.ListFolders(&workID) + if err != nil { + t.Fatalf("failed to list folders: %v", err) + } + if folders[0].Path != "Work/APIs" { + t.Errorf("expected path 'Work/APIs', got '%s'", folders[0].Path) + } + }) +} + +func TestUpdateFolder(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder + folder := &Folder{ID: "update-id", Name: "Original"} + v.CreateFolder(folder) + + t.Run("rename folder", func(t *testing.T) { + folder.Name = "Renamed" + if err := v.UpdateFolder(folder); err != nil { + t.Fatalf("failed to update folder: %v", err) + } + + got, _ := v.GetFolder("update-id") + if got.Name != "Renamed" { + t.Errorf("expected 'Renamed', got '%s'", got.Name) + } + }) + + t.Run("update icon and color", func(t *testing.T) { + folder.Icon = "📁" + folder.Color = "#FF0000" + if err := v.UpdateFolder(folder); err != nil { + t.Fatalf("failed to update folder: %v", err) + } + + got, _ := v.GetFolder("update-id") + if got.Icon != "📁" { + t.Errorf("expected icon '📁', got '%s'", got.Icon) + } + if got.Color != "#FF0000" { + t.Errorf("expected color '#FF0000', got '%s'", got.Color) + } + }) +} + +func TestDeleteFolder(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + t.Run("delete empty folder", func(t *testing.T) { + folder := &Folder{ID: "delete-empty", Name: "ToDelete"} + v.CreateFolder(folder) + + if err := v.DeleteFolder("delete-empty", false); err != nil { + t.Fatalf("failed to delete folder: %v", err) + } + + _, err := v.GetFolder("delete-empty") + if err == nil { + t.Fatal("folder should be deleted") + } + }) + + t.Run("delete folder with secrets fails without force", func(t *testing.T) { + folder := &Folder{ID: "delete-secrets", Name: "WithSecrets"} + v.CreateFolder(folder) + + // Add a secret to the folder + folderID := "delete-secrets" + entry := &SecretEntry{Value: []byte("test"), FolderID: &folderID} + v.SetSecret("test-key", entry) + + err := v.DeleteFolder("delete-secrets", false) + if err == nil { + t.Fatal("expected error when deleting folder with secrets") + } + }) + + t.Run("delete folder with secrets succeeds with force", func(t *testing.T) { + folder := &Folder{ID: "delete-force", Name: "WithSecretsForce"} + v.CreateFolder(folder) + + folderID := "delete-force" + entry := &SecretEntry{Value: []byte("test2"), FolderID: &folderID} + v.SetSecret("test-key-2", entry) + + if err := v.DeleteFolder("delete-force", true); err != nil { + t.Fatalf("failed to delete folder with force: %v", err) + } + + // Secret should be unfiled + got, _ := v.GetSecret("test-key-2") + if got.FolderID != nil { + t.Errorf("secret should be unfiled after folder deletion") + } + }) + + t.Run("delete folder with children fails without force", func(t *testing.T) { + parent := &Folder{ID: "delete-parent", Name: "Parent"} + v.CreateFolder(parent) + + parentID := "delete-parent" + child := &Folder{ID: "delete-child", Name: "Child", ParentID: &parentID} + v.CreateFolder(child) + + err := v.DeleteFolder("delete-parent", false) + if err == nil { + t.Fatal("expected error when deleting folder with children") + } + }) +} + +func TestMoveSecretToFolder(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder + folder := &Folder{ID: "move-folder", Name: "Target"} + v.CreateFolder(folder) + + // Create secret + entry := &SecretEntry{Value: []byte("secret")} + v.SetSecret("move-test", entry) + + t.Run("move secret to folder", func(t *testing.T) { + folderID := "move-folder" + if err := v.MoveSecretToFolder("move-test", &folderID); err != nil { + t.Fatalf("failed to move secret: %v", err) + } + + got, _ := v.GetSecret("move-test") + if got.FolderID == nil || *got.FolderID != "move-folder" { + t.Errorf("secret should be in folder 'move-folder'") + } + }) + + t.Run("unfile secret", func(t *testing.T) { + if err := v.MoveSecretToFolder("move-test", nil); err != nil { + t.Fatalf("failed to unfile secret: %v", err) + } + + got, _ := v.GetSecret("move-test") + if got.FolderID != nil { + t.Errorf("secret should be unfiled") + } + }) +} + +func TestListSecretsInFolder(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder hierarchy + work := &Folder{ID: "list-work", Name: "Work"} + v.CreateFolder(work) + + workID := "list-work" + apis := &Folder{ID: "list-apis", Name: "APIs", ParentID: &workID} + v.CreateFolder(apis) + + // Add secrets to different folders + apisID := "list-apis" + entry1 := &SecretEntry{Value: []byte("s1"), FolderID: &workID} + v.SetSecret("work-secret", entry1) + + entry2 := &SecretEntry{Value: []byte("s2"), FolderID: &apisID} + v.SetSecret("api-secret", entry2) + + entry3 := &SecretEntry{Value: []byte("s3")} + v.SetSecret("unfiled-secret", entry3) + + t.Run("list secrets in folder", func(t *testing.T) { + entries, err := v.ListSecretsInFolder(&workID, false) + if err != nil { + t.Fatalf("failed to list secrets: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 secret, got %d", len(entries)) + } + if entries[0].Key != "work-secret" { + t.Errorf("expected 'work-secret', got '%s'", entries[0].Key) + } + }) + + t.Run("list secrets recursively", func(t *testing.T) { + entries, err := v.ListSecretsInFolder(&workID, true) + if err != nil { + t.Fatalf("failed to list secrets: %v", err) + } + if len(entries) != 2 { + t.Errorf("expected 2 secrets recursively, got %d", len(entries)) + } + }) + + t.Run("list unfiled secrets", func(t *testing.T) { + entries, err := v.ListSecretsInFolder(nil, false) + if err != nil { + t.Fatalf("failed to list unfiled secrets: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 unfiled secret, got %d", len(entries)) + } + if entries[0].Key != "unfiled-secret" { + t.Errorf("expected 'unfiled-secret', got '%s'", entries[0].Key) + } + }) +} + +func TestFolderDepthLimit(t *testing.T) { + v, _, cleanup := setupTestVaultForFolder(t) + defer cleanup() + + // Create folder hierarchy up to max depth (10) + var lastID string + for i := 0; i < MaxFolderDepth; i++ { + folder := &Folder{ + ID: string(rune('a' + i)), + Name: string(rune('A' + i)), + } + if lastID != "" { + folder.ParentID = &lastID + } + if err := v.CreateFolder(folder); err != nil { + t.Fatalf("failed to create folder at depth %d: %v", i, err) + } + lastID = folder.ID + } + + // Try to create one more level (should fail) + folder := &Folder{ + ID: "too-deep", + Name: "TooDeep", + ParentID: &lastID, + } + err := v.CreateFolder(folder) + if err == nil { + t.Fatal("expected error when exceeding max folder depth") + } +} diff --git a/pkg/vault/migration.go b/pkg/vault/migration.go index 36bbd45..1398924 100644 --- a/pkg/vault/migration.go +++ b/pkg/vault/migration.go @@ -18,8 +18,10 @@ const ( SchemaVersion3 = 3 // SchemaVersion4 adds salt column to vault_keys (Phase 2c-P: Password Change) SchemaVersion4 = 4 + // SchemaVersion5 adds folders table and folder_id column (Phase 2c-X2: Folder Feature) + SchemaVersion5 = 5 // CurrentSchemaVersion is the current schema version - CurrentSchemaVersion = SchemaVersion4 + CurrentSchemaVersion = SchemaVersion5 ) // getSchemaVersion returns the current schema version from the database. @@ -102,6 +104,12 @@ func migrateSchema(db *sql.DB, vaultPath string) error { } } + if version < SchemaVersion5 { + if err := migrateToV5(db); err != nil { + return fmt.Errorf("vault: migration to v5 failed: %w", err) + } + } + return nil } @@ -310,6 +318,116 @@ func migrateToV4(db *sql.DB, vaultPath string) error { return nil } +// migrateToV5 adds folders table and folder_id column for folder feature (ADR-007). +// This migration: +// 1. Creates folders table with hierarchical structure +// 2. Adds folder_id column to secrets table +// 3. Creates indexes for efficient queries +// 4. Updates schema version +// +// Note: Existing secrets remain "unfiled" (folder_id = NULL) after migration. +func migrateToV5(db *sql.DB) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Enable foreign keys for this connection (required for ON DELETE RESTRICT) + _, err = tx.Exec("PRAGMA foreign_keys = ON") + if err != nil { + return fmt.Errorf("failed to enable foreign keys: %w", err) + } + + // Check if folders table already exists (idempotent migration) + var tableName string + err = tx.QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='folders' + `).Scan(&tableName) + + if err == sql.ErrNoRows { + // Create folders table per ADR-007 + _, err = tx.Exec(` + CREATE TABLE folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_id TEXT, + icon TEXT, + color TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE RESTRICT, + CHECK (name NOT LIKE '%/%') + ) + `) + if err != nil { + return fmt.Errorf("failed to create folders table: %w", err) + } + + // Create index for parent lookups + _, err = tx.Exec("CREATE INDEX idx_folders_parent ON folders(parent_id)") + if err != nil { + return fmt.Errorf("failed to create idx_folders_parent: %w", err) + } + + // Create unique index for name within same parent (non-NULL parent) + _, err = tx.Exec(` + CREATE UNIQUE INDEX idx_folders_name_parent ON folders(name COLLATE NOCASE, parent_id) + WHERE parent_id IS NOT NULL + `) + if err != nil { + return fmt.Errorf("failed to create idx_folders_name_parent: %w", err) + } + + // Create unique index for root folder names (NULL parent) + _, err = tx.Exec(` + CREATE UNIQUE INDEX idx_folders_root_name ON folders(name COLLATE NOCASE) + WHERE parent_id IS NULL + `) + if err != nil { + return fmt.Errorf("failed to create idx_folders_root_name: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed to check folders table: %w", err) + } + + // Check if folder_id column already exists in secrets + columns, err := getTableColumns(tx, "secrets") + if err != nil { + return fmt.Errorf("failed to get secrets columns: %w", err) + } + + // Add folder_id column if it doesn't exist + if !columns["folder_id"] { + // Note: SQLite doesn't support adding foreign key constraints via ALTER TABLE + // The constraint is enforced at application level for existing tables + _, err = tx.Exec("ALTER TABLE secrets ADD COLUMN folder_id TEXT") + if err != nil { + return fmt.Errorf("failed to add folder_id column: %w", err) + } + + // Create index for folder lookups + _, err = tx.Exec("CREATE INDEX idx_secrets_folder ON secrets(folder_id)") + if err != nil { + return fmt.Errorf("failed to create idx_secrets_folder: %w", err) + } + } + + // Update schema version + _, err = tx.Exec("INSERT OR REPLACE INTO schema_version (version) VALUES (?)", SchemaVersion5) + if err != nil { + return fmt.Errorf("failed to set schema version: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration: %w", err) + } + + return nil +} + // getTableColumnsFromDB returns a map of column names for a table using db connection. // Unlike getTableColumns, this uses *sql.DB instead of *sql.Tx. func getTableColumnsFromDB(db *sql.DB, tableName string) (map[string]bool, error) { diff --git a/pkg/vault/vault.go b/pkg/vault/vault.go index 547f0ff..4dd3c29 100644 --- a/pkg/vault/vault.go +++ b/pkg/vault/vault.go @@ -259,6 +259,9 @@ type SecretMetadata struct { // - Bindings: environment variable name to field name mapping // - Schema: reserved for Phase 3 schema validation // +// Phase 2c-X2 Folder Support (ADR-007): +// - FolderID: reference to folder for organization (NULL = unfiled) +// // Backward Compatibility: // - Value field is deprecated but still supported for reading legacy secrets // - Legacy secrets are auto-converted to Fields["value"] on read @@ -269,6 +272,7 @@ type SecretEntry struct { Fields map[string]Field // Multi-field values (Phase 2.5+) Bindings map[string]string // Environment variable bindings: env_var_name -> field_name Schema string // Reserved for Phase 3 schema validation + FolderID *string // Reference to folder (Phase 2c-X2, NULL = unfiled) Metadata *SecretMetadata // Encrypted metadata (notes, url) Tags []string // Plaintext: searchable tags ExpiresAt *time.Time // Plaintext: expiration date @@ -551,6 +555,15 @@ func (v *Vault) Unlock(masterPassword string) error { return fmt.Errorf("vault: schema migration failed: %w", err) } + // 7. Enable foreign keys (required for ON DELETE RESTRICT per ADR-007) + _, err = db.Exec("PRAGMA foreign_keys = ON") + if err != nil { + v.dek = nil + v.db = nil + db.Close() + return fmt.Errorf("vault: failed to enable foreign keys: %w", err) + } + // Clear lock state on successful unlock if err := v.clearLockState(); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to clear lock state: %v\n", err) @@ -787,8 +800,14 @@ func (v *Vault) checkAndWarnPermissions() { // createTables creates the required SQLite tables func (v *Vault) createTables(db *sql.DB) error { + // Enable foreign keys (required for ON DELETE RESTRICT per ADR-007) + _, err := db.Exec("PRAGMA foreign_keys = ON") + if err != nil { + return fmt.Errorf("failed to enable foreign keys: %w", err) + } + // vault_keys table (encrypted DEK + salt per ADR-003) - _, err := db.Exec(` + _, err = db.Exec(` CREATE TABLE IF NOT EXISTS vault_keys ( id INTEGER PRIMARY KEY, salt BLOB NOT NULL, @@ -801,9 +820,54 @@ func (v *Vault) createTables(db *sql.DB) error { return err } + // folders table (per ADR-007: Folder Feature) + // - id: UUID primary key + // - name: display name (no "/" allowed) + // - parent_id: NULL for root, UUID for nested + // - icon/color: optional visual customization + // - sort_order: for manual ordering + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_id TEXT, + icon TEXT, + color TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE RESTRICT, + CHECK (name NOT LIKE '%/%') + ) + `) + if err != nil { + return err + } + + // Folder indexes + _, err = db.Exec("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id)") + if err != nil { + return err + } + + // Unique index for nested folder names (case-insensitive) + // Per ADR-007: Unique (name, parent_id) constraint with case-insensitive matching + _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_name_parent ON folders(name COLLATE NOCASE, parent_id) WHERE parent_id IS NOT NULL`) + if err != nil { + return err + } + + // Unique index for root folder names (NULL parent) + // SQLite requires separate index for NULL values in partial index + _, err = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_root_name ON folders(name COLLATE NOCASE) WHERE parent_id IS NULL`) + if err != nil { + return err + } + // secrets table (hybrid approach: encrypted metadata JSON + plaintext search fields) // Per project-proposal-ja.md Phase 0: Metadata support (notes/url/tags/expires_at) // Per ADR-002 Phase 2.5: Multi-field secrets support + // Per ADR-007 Phase 2c-X2: Folder support // - encrypted_key: encrypted key name (nonce prepended) // - encrypted_value: legacy single value (nonce prepended) - kept for backward compatibility // - encrypted_fields: encrypted JSON map of Field structs (Phase 2.5+) @@ -811,6 +875,7 @@ func (v *Vault) createTables(db *sql.DB) error { // - encrypted_metadata: encrypted notes/url JSON // - schema: plaintext schema name (reserved for Phase 3) // - field_count: plaintext field count for MCP secret_list (Phase 2.5+) + // - folder_id: reference to folder (Phase 2c-X2, NULL = unfiled) // - tags, expires_at: plaintext for searchability _, err = db.Exec(` CREATE TABLE IF NOT EXISTS secrets ( @@ -823,6 +888,7 @@ func (v *Vault) createTables(db *sql.DB) error { encrypted_metadata BLOB, schema TEXT, field_count INTEGER DEFAULT 1, + folder_id TEXT REFERENCES folders(id) ON DELETE RESTRICT, tags TEXT, expires_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -833,6 +899,12 @@ func (v *Vault) createTables(db *sql.DB) error { return err } + // Secret folder index + _, err = db.Exec("CREATE INDEX IF NOT EXISTS idx_secrets_folder ON secrets(folder_id)") + if err != nil { + return err + } + // schema_version table for migration tracking _, err = db.Exec(` CREATE TABLE IF NOT EXISTS schema_version ( @@ -1161,9 +1233,10 @@ func (v *Vault) SetSecret(key string, entry *SecretEntry) error { // UPSERT: update if key exists, insert otherwise // Store both legacy format (encrypted_value) and new format (encrypted_fields) + // Per ADR-007: folder_id is stored as plaintext reference to folders table _, err = tx.Exec(` - INSERT INTO secrets (key_hash, encrypted_key, encrypted_value, encrypted_fields, encrypted_bindings, encrypted_metadata, schema, field_count, tags, expires_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO secrets (key_hash, encrypted_key, encrypted_value, encrypted_fields, encrypted_bindings, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key_hash) DO UPDATE SET encrypted_key = excluded.encrypted_key, encrypted_value = excluded.encrypted_value, @@ -1172,10 +1245,11 @@ func (v *Vault) SetSecret(key string, entry *SecretEntry) error { encrypted_metadata = excluded.encrypted_metadata, schema = excluded.schema, field_count = excluded.field_count, + folder_id = excluded.folder_id, tags = excluded.tags, expires_at = excluded.expires_at, updated_at = CURRENT_TIMESTAMP - `, keyHash, encryptedKey, encryptedValue, encryptedFields, encryptedBindings, encryptedMetadata, entry.Schema, fieldCount, tagsStr, expiresAt) + `, keyHash, encryptedKey, encryptedValue, encryptedFields, encryptedBindings, encryptedMetadata, entry.Schema, fieldCount, entry.FolderID, tagsStr, expiresAt) if err != nil { _ = v.audit.LogError(audit.OpSecretSet, audit.SourceCLI, key, "DB_ERROR", err.Error()) return fmt.Errorf("vault: failed to save secret: %w", err) @@ -1213,15 +1287,16 @@ func (v *Vault) GetSecret(key string) (*SecretEntry, error) { // Get all fields from database (including new multi-field columns) var encryptedValue, encryptedFields, encryptedBindings, encryptedMetadata []byte var schema sql.NullString + var folderID sql.NullString var tagsStr sql.NullString var expiresAt sql.NullTime var createdAt, updatedAt time.Time err := v.db.QueryRow(` - SELECT encrypted_value, encrypted_fields, encrypted_bindings, encrypted_metadata, schema, tags, expires_at, created_at, updated_at + SELECT encrypted_value, encrypted_fields, encrypted_bindings, encrypted_metadata, schema, folder_id, tags, expires_at, created_at, updated_at FROM secrets WHERE key_hash = ?`, keyHash, - ).Scan(&encryptedValue, &encryptedFields, &encryptedBindings, &encryptedMetadata, &schema, &tagsStr, &expiresAt, &createdAt, &updatedAt) + ).Scan(&encryptedValue, &encryptedFields, &encryptedBindings, &encryptedMetadata, &schema, &folderID, &tagsStr, &expiresAt, &createdAt, &updatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { _ = v.audit.LogError(audit.OpSecretGet, audit.SourceCLI, key, "NOT_FOUND", "secret not found") @@ -1236,6 +1311,11 @@ func (v *Vault) GetSecret(key string) (*SecretEntry, error) { UpdatedAt: updatedAt, } + // Set folder ID if present (Phase 2c-X2) + if folderID.Valid { + entry.FolderID = &folderID.String + } + // Decrypt fields (new format) or value (legacy format) if len(encryptedFields) > 0 { // New multi-field format @@ -1409,16 +1489,20 @@ func (v *Vault) DeleteSecret(key string) error { // Multi-field support (Phase 2.5): // - Also reads schema column for Phase 3 compatibility // - Does NOT decrypt fields/bindings (not needed for list operations) +// +// Folder support (Phase 2c-X2): +// - Reads folder_id for folder-based filtering and display func (v *Vault) scanSecretEntryRowWithMetadata(rows *sql.Rows) (*SecretEntry, error) { var encryptedKey []byte var encryptedMetadata []byte var schema sql.NullString var fieldCount sql.NullInt64 + var folderID sql.NullString var tagsStr sql.NullString var expiresAt sql.NullTime var createdAt, updatedAt time.Time - if err := rows.Scan(&encryptedKey, &encryptedMetadata, &schema, &fieldCount, &tagsStr, &expiresAt, + if err := rows.Scan(&encryptedKey, &encryptedMetadata, &schema, &fieldCount, &folderID, &tagsStr, &expiresAt, &createdAt, &updatedAt); err != nil { return nil, fmt.Errorf("vault: failed to scan row: %w", err) } @@ -1446,6 +1530,11 @@ func (v *Vault) scanSecretEntryRowWithMetadata(rows *sql.Rows) (*SecretEntry, er entry.Schema = schema.String } + // Set folder ID if present (Phase 2c-X2) + if folderID.Valid { + entry.FolderID = &folderID.String + } + // Decrypt metadata if present (small data - notes/URL, not credentials) if len(encryptedMetadata) > 0 { metadataJSON, err := v.decryptWithNonce(encryptedMetadata) @@ -1488,7 +1577,7 @@ func (v *Vault) ListSecretsWithMetadata() ([]*SecretEntry, error) { } rows, err := v.db.Query(` - SELECT encrypted_key, encrypted_metadata, schema, field_count, tags, expires_at, created_at, updated_at + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at FROM secrets ORDER BY created_at`) if err != nil { @@ -1526,7 +1615,7 @@ func (v *Vault) ListSecretsByTag(tag string) ([]*SecretEntry, error) { // This searches for the tag within the JSON array string // Include encrypted_metadata and schema for HasNotes/HasURL support and Phase 3 rows, err := v.db.Query(` - SELECT encrypted_key, encrypted_metadata, schema, field_count, tags, expires_at, created_at, updated_at + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at FROM secrets WHERE tags LIKE ? ORDER BY created_at`, @@ -1572,7 +1661,7 @@ func (v *Vault) ListExpiringSecrets(within time.Duration) ([]*SecretEntry, error // Include encrypted_metadata and schema for HasNotes/HasURL support and Phase 3 rows, err := v.db.Query(` - SELECT encrypted_key, encrypted_metadata, schema, field_count, tags, expires_at, created_at, updated_at + SELECT encrypted_key, encrypted_metadata, schema, field_count, folder_id, tags, expires_at, created_at, updated_at FROM secrets WHERE expires_at IS NOT NULL AND expires_at <= ? ORDER BY expires_at`,