Skip to content
Merged
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
5 changes: 3 additions & 2 deletions cmd/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Examples:
# Preview changes without modifying the file
down-force complete evidence.yaml --dry-run
`,
Args: cobra.ExactArgs(1),
Run: runComplete,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeEvidenceYAMLArg,
Run: runComplete,
}

func init() {
Expand Down
227 changes: 227 additions & 0 deletions cmd/completion_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/*
Copyright © 2025 canaria-computer
*/
package cmd

import (
"os"
"path/filepath"
"sort"
"strings"

"github.com/canaria-computer/down-force/internal/config"
"github.com/spf13/cobra"
)

// RegisterAllCompletions registers all flag completion functions for the CLI.
// Call this after all commands are initialized.
func RegisterAllCompletions() {
// email --report: complete with report-* directories
_ = emailCmd.RegisterFlagCompletionFunc("report", completeReportDirectories)

// devices --name: complete with device names
_ = devicesCmd.RegisterFlagCompletionFunc("name", completeDeviceNames)

// lite --config: complete with yaml files
_ = liteCmd.RegisterFlagCompletionFunc("config", completeYAMLFiles)

// root --config-file: complete with yaml files
_ = rootCmd.RegisterFlagCompletionFunc("config-file", completeConfigFiles)
}

// completeReportDirectories provides completion for report directories
func completeReportDirectories(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cwd, err := os.Getwd()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

entries, err := os.ReadDir(cwd)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

var completions []string
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") {
name := entry.Name()
if strings.HasPrefix(name, toComplete) {
completions = append(completions, name)
}
}
}

// Sort by name (newest first by timestamp convention)
sort.Sort(sort.Reverse(sort.StringSlice(completions)))

return completions, cobra.ShellCompDirectiveNoFileComp
}

// completeDeviceNames provides completion for device names
func completeDeviceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
allDevices := config.GetAllPresetDevices()

var completions []string
toCompleteLower := strings.ToLower(toComplete)

for name, device := range allDevices {
nameLower := strings.ToLower(name)
titleLower := strings.ToLower(device.Title)

if strings.HasPrefix(nameLower, toCompleteLower) ||
strings.HasPrefix(titleLower, toCompleteLower) ||
strings.Contains(nameLower, toCompleteLower) ||
strings.Contains(titleLower, toCompleteLower) {
// Use device title as completion value
completions = append(completions, device.Title)
}
}

sort.Strings(completions)

// Remove duplicates
completions = uniqueStrings(completions)

return completions, cobra.ShellCompDirectiveNoFileComp
}

// completeYAMLFiles provides completion for YAML config files
func completeYAMLFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cwd, err := os.Getwd()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

var completions []string

// Match yaml/yml files
patterns := []string{"*.yaml", "*.yml"}
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(cwd, pattern))
if err != nil {
continue
}
for _, match := range matches {
base := filepath.Base(match)
if strings.HasPrefix(base, toComplete) || toComplete == "" {
completions = append(completions, base)
}
}
}

// Also check subdirectories for evidence.yaml
entries, _ := os.ReadDir(cwd)
for _, entry := range entries {
if entry.IsDir() {
subPath := filepath.Join(cwd, entry.Name(), "evidence.yaml")
if _, err := os.Stat(subPath); err == nil {
relPath := filepath.Join(entry.Name(), "evidence.yaml")
if strings.HasPrefix(relPath, toComplete) || toComplete == "" {
completions = append(completions, relPath)
}
}
}
}

sort.Strings(completions)

return completions, cobra.ShellCompDirectiveDefault
}

// completeConfigFiles provides completion for application config files
func completeConfigFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var completions []string

// Add default config location
homeDir, err := os.UserHomeDir()
if err == nil {
defaultPath := filepath.Join(homeDir, ".config", "down-force", "config.yaml")
if _, err := os.Stat(defaultPath); err == nil {
completions = append(completions, defaultPath)
}
}

// Also complete local yaml files
localCompletions, _ := completeYAMLFiles(cmd, args, toComplete)
completions = append(completions, localCompletions...)

return completions, cobra.ShellCompDirectiveDefault
}

// uniqueStrings removes duplicate strings from a sorted slice
func uniqueStrings(s []string) []string {
if len(s) == 0 {
return s
}

result := make([]string, 0, len(s))
prev := ""
for _, str := range s {
if str != prev {
result = append(result, str)
prev = str
}
}
return result
}

// SMTPSecureValues returns valid values for SMTP secure mode
var SMTPSecureValues = []string{"ssl", "tls", "starttls", "none"}

// SMTPAuthTypes returns valid values for SMTP authentication type
var SMTPAuthTypes = []string{"AUTO", "PLAIN", "LOGIN", "CRAM-MD5"}

// IMAPSecureValues returns valid values for IMAP secure mode
var IMAPSecureValues = []string{"ssl", "tls", "starttls", "none"}

// completeEvidenceYAMLArg provides completion for evidence.yaml positional argument
func completeEvidenceYAMLArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) >= 1 {
// Only complete first argument
return nil, cobra.ShellCompDirectiveNoFileComp
}
return completeYAMLFiles(cmd, args, toComplete)
}

// completeEMLFileArg provides completion for .eml file positional argument
func completeEMLFileArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) >= 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}

cwd, err := os.Getwd()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

var completions []string

// Match .eml files in current directory
matches, err := filepath.Glob(filepath.Join(cwd, "*.eml"))
if err == nil {
for _, match := range matches {
base := filepath.Base(match)
if strings.HasPrefix(base, toComplete) || toComplete == "" {
completions = append(completions, base)
}
}
}

// Also check in report-* directories
entries, _ := os.ReadDir(cwd)
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") {
emlMatches, _ := filepath.Glob(filepath.Join(cwd, entry.Name(), "*.eml"))
for _, match := range emlMatches {
relPath, _ := filepath.Rel(cwd, match)
if strings.HasPrefix(relPath, toComplete) || toComplete == "" {
completions = append(completions, relPath)
}
}
}
}

sort.Strings(completions)

return completions, cobra.ShellCompDirectiveDefault
}
19 changes: 8 additions & 11 deletions cmd/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ Examples:
Configuration:
SMTP server settings must be configured before sending.
Use 'down-force config email' for SMTP setup.`,
Args: cobra.ExactArgs(1),
Run: runEmailResend,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeEMLFileArg,
Run: runEmailResend,
}

func init() {
Expand Down Expand Up @@ -364,7 +365,7 @@ func runEmailDraft(cmd *cobra.Command, args []string) {
if emailDraftExport {
log.Info("Exporting draft as .eml file...")
log.Infof("HTML Sanitization: %v", !effectiveAllowHTML)

if err := saveEmlFile(msg, reportDir); err != nil {
log.Errorf("Failed to save .eml file: %v", err)
} else {
Expand Down Expand Up @@ -641,7 +642,6 @@ func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}


func runEmailSend(cmd *cobra.Command, args []string) {
cfg := appconfig.GetConfig()

Expand Down Expand Up @@ -786,13 +786,10 @@ func getReportDirectory(reportDir string) (string, error) {
}
var reportDirs []string
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") {
hasFile, err := hasAnyFile(entry.Name())
if err != nil {
log.Error("Failed to check if directory is empty: %v", err)
return "", err
}
if hasFile {
if entry.IsDir() {
// Check if the directory contains Report.md (evidence report directory)
reportFile := filepath.Join(entry.Name(), "Report.md")
if _, err := os.Stat(reportFile); err == nil {
reportDirs = append(reportDirs, entry.Name())
}
}
Expand Down
16 changes: 9 additions & 7 deletions cmd/lite.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,23 +165,25 @@ func performPreflightChecks(targetURL string, userAgents []config.UserAgentConfi
}

// generateReportID creates a unique report ID from config or UNIX timestamp.
// Returns Report ID without "report-" prefix.
// If configReportID is empty, returns "report-{timestamp}" format.
// If configReportID is provided, returns it as-is.
func generateReportID(configReportID string) string {
if configReportID != "" {
// Remove "report-" prefix if present (for compatibility)
return strings.TrimPrefix(configReportID, "report-")
// Use the specified report_id as-is
return configReportID
}

// Generate Base58-encoded UNIX timestamp
// Generate report-{timestamp} format for default case
ts := time.Now().Unix()
tsBytes := make([]byte, 8)
binary.BigEndian.PutUint64(tsBytes, uint64(ts))
return base58Encode(tsBytes)
return fmt.Sprintf("report-%s", base58Encode(tsBytes))
}

// getReportDir creates directory name from Report ID
// getReportDir returns the directory name directly
// (reportID is already in the correct format)
func getReportDir(reportID string) string {
return fmt.Sprintf("report-%s", reportID)
return reportID
}

// base58Encode encodes bytes to Base58 string
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ var rootCmd = &cobra.Command{
}

func Execute() {
// Register all flag completion functions
RegisterAllCompletions()

err := rootCmd.Execute()
if err != nil {
os.Exit(1)
Expand Down
5 changes: 3 additions & 2 deletions cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ Examples:
# Lenient validation (schema only)
down-force validate evidence.yaml --lax
`,
Args: cobra.ExactArgs(1),
Run: runValidate,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeEvidenceYAMLArg,
Run: runValidate,
}

func init() {
Expand Down
20 changes: 11 additions & 9 deletions internal/utils/email_address.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,22 @@ type PlaceholderContext struct {
}

// NewPlaceholderContextFromReportDir creates a PlaceholderContext from a report directory name
// Expected format: report-{REPORT_ID} (e.g., "report-MjAyNjAxMDgtMTc0NTQ0")
// The directory name is used as the report ID.
// If it starts with "report-", the prefix is removed for the report ID.
func NewPlaceholderContextFromReportDir(reportDir string) (*PlaceholderContext, error) {
basename := filepath.Base(reportDir)

// Directory name must start with "report-"
if !strings.HasPrefix(basename, "report-") {
return nil, fmt.Errorf("invalid report directory format (expected 'report-*'): %s", basename)
if basename == "" {
return nil, fmt.Errorf("report directory name is empty")
}

// Extract Report ID by removing "report-" prefix
reportID := strings.TrimPrefix(basename, "report-")

if reportID == "" {
return nil, fmt.Errorf("report ID is empty in directory: %s", basename)
// Extract Report ID: remove "report-" prefix if present
reportID := basename
if strings.HasPrefix(basename, "report-") {
reportID = strings.TrimPrefix(basename, "report-")
if reportID == "" {
return nil, fmt.Errorf("report ID is empty in directory: %s", basename)
}
}

return &PlaceholderContext{
Expand Down
Loading