diff --git a/cmd/proxsave/env_migration.go b/cmd/proxsave/env_migration.go index fba54c9..c410af4 100644 --- a/cmd/proxsave/env_migration.go +++ b/cmd/proxsave/env_migration.go @@ -10,6 +10,7 @@ import ( "github.com/tis24dev/proxsave/internal/cli" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" "github.com/tis24dev/proxsave/internal/types" ) @@ -111,15 +112,15 @@ func resolveLegacyEnvPath(ctx context.Context, args *cli.Args, bootstrap *loggin question := fmt.Sprintf("Enter the path to the legacy Bash backup.env [%s]: ", defaultPromptPath) for { fmt.Print(question) - input, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { return "", err } - input = strings.TrimSpace(input) - if input == "" { + line = strings.TrimSpace(line) + if line == "" { legacyPath = defaultPromptPath } else { - legacyPath = input + legacyPath = line } if legacyPath == "" { continue diff --git a/cmd/proxsave/helpers_test.go b/cmd/proxsave/helpers_test.go index e5cdd46..bb2eb04 100644 --- a/cmd/proxsave/helpers_test.go +++ b/cmd/proxsave/helpers_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" ) // ============================================================ @@ -347,22 +348,22 @@ func TestAddPathExclusion_DuplicateAddsAgain(t *testing.T) { // prompts.go tests // ============================================================ -func TestMapPromptInputError(t *testing.T) { +func TestInputMapInputError(t *testing.T) { tests := []struct { name string err error expected error }{ {"nil error", nil, nil}, - {"EOF", io.EOF, errPromptInputClosed}, - {"closed file", errors.New("use of closed file"), errPromptInputClosed}, - {"bad fd", errors.New("bad file descriptor"), errPromptInputClosed}, + {"EOF", io.EOF, input.ErrInputAborted}, + {"closed file", errors.New("use of closed file"), input.ErrInputAborted}, + {"bad fd", errors.New("bad file descriptor"), input.ErrInputAborted}, {"other error", errors.New("something else"), errors.New("something else")}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := mapPromptInputError(tc.err) + result := input.MapInputError(tc.err) if tc.expected == nil { if result != nil { @@ -371,9 +372,9 @@ func TestMapPromptInputError(t *testing.T) { return } - if errors.Is(tc.expected, errPromptInputClosed) { - if !errors.Is(result, errPromptInputClosed) { - t.Errorf("expected errPromptInputClosed, got %v", result) + if errors.Is(tc.expected, input.ErrInputAborted) { + if !errors.Is(result, input.ErrInputAborted) { + t.Errorf("expected ErrInputAborted, got %v", result) } return } diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index 413e6e8..675fecd 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -228,7 +228,7 @@ func printInstallFooter(installErr error, configPath, baseDir, telegramCode, per fmt.Println(" --decrypt - Decrypt an existing backup archive") fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") - fmt.Println(" --support - Run backup in support mode (force debug log level and send email with attached log to github-support@tis24.it)") + fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") fmt.Println() } diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index cb7ddea..4e9fc18 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "context" "errors" "fmt" @@ -28,6 +27,8 @@ import ( "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/security" "github.com/tis24dev/proxsave/internal/storage" + "github.com/tis24dev/proxsave/internal/support" + "github.com/tis24dev/proxsave/internal/tui" "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -87,6 +88,7 @@ func run() int { // Setup signal handling for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() + tui.SetAbortContext(ctx) // Handle SIGINT (Ctrl+C) and SIGTERM sigChan := make(chan os.Signal, 1) @@ -94,7 +96,7 @@ func run() int { go func() { sig := <-sigChan logging.DebugStepBootstrap(bootstrap, "signal", "received=%v", sig) - bootstrap.Warning("\nReceived signal %v, initiating graceful shutdown...", sig) + bootstrap.Info("\nReceived signal %v, initiating graceful shutdown...", sig) cancel() // Cancel context to stop all operations closeStdinOnce.Do(func() { if file := os.Stdin; file != nil { @@ -124,7 +126,7 @@ func run() int { if args.Support { incompatible := make([]string, 0, 6) if args.Restore { - incompatible = append(incompatible, "--restore") + // allowed } if args.Decrypt { incompatible = append(incompatible, "--decrypt") @@ -147,7 +149,7 @@ func run() int { if len(incompatible) > 0 { bootstrap.Error("Support mode cannot be combined with: %s", strings.Join(incompatible, ", ")) - bootstrap.Error("--support is only available for the standard backup run.") + bootstrap.Error("--support is only available for the standard backup run or --restore.") return types.ExitConfigError.Int() } } @@ -402,8 +404,11 @@ func run() int { // Support mode: interactive pre-flight questionnaire (mandatory) if args.Support { logging.DebugStepBootstrap(bootstrap, "main run", "mode=support") - continueRun, interrupted := runSupportIntro(ctx, bootstrap, args) - if !continueRun { + meta, continueRun, interrupted := support.RunIntro(ctx, bootstrap) + if continueRun { + args.SupportGitHubUser = meta.GitHubUser + args.SupportIssueID = meta.IssueID + } else { if interrupted { // Interrupted by signal (Ctrl+C): set exit code and still show footer. finalize(exitCodeInterrupted) @@ -603,7 +608,10 @@ func run() int { return } logging.Step("Support mode - sending support email with attached log") - sendSupportEmail(ctx, cfg, logger, envInfo.Type, pendingSupportStats, args.SupportGitHubUser, args.SupportIssueID) + support.SendEmail(ctx, cfg, logger, envInfo.Type, pendingSupportStats, support.Meta{ + GitHubUser: args.SupportGitHubUser, + IssueID: args.SupportIssueID, + }, buildSignature()) }() // Defer for early error notifications @@ -739,9 +747,15 @@ func run() int { if err := orchestrator.RunRestoreWorkflow(ctx, cfg, logger, toolVersion); err != nil { if errors.Is(err, orchestrator.ErrRestoreAborted) { logging.Info("Restore workflow aborted by user") + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") + } return finalize(exitCodeInterrupted) } logging.Error("Restore workflow failed: %v", err) + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") + } return finalize(types.ExitGenericError.Int()) } if logger.HasWarnings() { @@ -749,6 +763,9 @@ func run() int { } else { logging.Info("Restore workflow completed successfully") } + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") + } return finalize(types.ExitSuccess.Int()) } @@ -760,9 +777,15 @@ func run() int { if err := orchestrator.RunRestoreWorkflowTUI(ctx, cfg, logger, toolVersion, args.ConfigPath, sig); err != nil { if errors.Is(err, orchestrator.ErrRestoreAborted) || errors.Is(err, orchestrator.ErrDecryptAborted) { logging.Info("Restore workflow aborted by user") + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), exitCodeInterrupted, "restore") + } return finalize(exitCodeInterrupted) } logging.Error("Restore workflow failed: %v", err) + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitGenericError.Int(), "restore") + } return finalize(types.ExitGenericError.Int()) } if logger.HasWarnings() { @@ -770,6 +793,9 @@ func run() int { } else { logging.Info("Restore workflow completed successfully") } + if args.Support { + pendingSupportStats = support.BuildSupportStats(logger, resolveHostname(), envInfo.Type, envInfo.Version, toolVersion, startTime, time.Now(), types.ExitSuccess.Int(), "restore") + } return finalize(types.ExitSuccess.Int()) } @@ -1409,177 +1435,8 @@ func printFinalSummary(finalExitCode int) { fmt.Println(" --decrypt - Decrypt an existing backup archive") fmt.Println(" --restore - Run interactive restore workflow (select bundle, decrypt if needed, apply to system)") fmt.Println(" --upgrade-config - Upgrade configuration file using the embedded template (run after installing a new binary)") - fmt.Println(" --support - Run backup in support mode (force debug log level and send email with attached log to github-support@tis24.it)") - fmt.Println() -} - -func sendSupportEmail(ctx context.Context, cfg *config.Config, logger *logging.Logger, proxmoxType types.ProxmoxType, stats *orchestrator.BackupStats, githubUser, issueID string) { - if stats == nil { - logging.Warning("Support mode: cannot send support email because stats are nil") - return - } - - subject := "SUPPORT REQUEST" - if strings.TrimSpace(githubUser) != "" || strings.TrimSpace(issueID) != "" { - subjectParts := []string{"SUPPORT REQUEST"} - if strings.TrimSpace(githubUser) != "" { - subjectParts = append(subjectParts, fmt.Sprintf("Nickname: %s", strings.TrimSpace(githubUser))) - } - if strings.TrimSpace(issueID) != "" { - subjectParts = append(subjectParts, fmt.Sprintf("Issue: %s", strings.TrimSpace(issueID))) - } - subject = strings.Join(subjectParts, " - ") - } - - if sig := buildSignature(); sig != "" { - subject = fmt.Sprintf("%s - Build: %s", subject, sig) - } - - emailConfig := notify.EmailConfig{ - Enabled: true, - DeliveryMethod: notify.EmailDeliverySendmail, - FallbackSendmail: false, - AttachLogFile: true, - Recipient: "github-support@tis24.it", - From: cfg.EmailFrom, - SubjectOverride: subject, - } - - emailNotifier, err := notify.NewEmailNotifier(emailConfig, proxmoxType, logger) - if err != nil { - logging.Warning("Support mode: failed to initialize support email notifier: %v", err) - return - } - - adapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) - if err := adapter.Notify(ctx, stats); err != nil { - logging.Critical("Support mode: FAILED to send support email: %v", err) - fmt.Println("\033[33m⚠️ CRITICAL: Support email failed to send!\033[0m") - return - } - - logging.Info("Support mode: support email handed off to local MTA for github-support@tis24.it (check mailq and /var/log/mail.log for delivery)") -} - -func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, args *cli.Args) (bool, bool) { - reader := bufio.NewReader(os.Stdin) - - fmt.Println() - fmt.Println("\033[32m================================================\033[0m") - fmt.Println("\033[32m SUPPORT & ASSISTANCE MODE\033[0m") - fmt.Println("\033[32m================================================\033[0m") + fmt.Println(" --support - Run in support mode (force debug log level and send email with attached log to github-support@tis24.it); available for standard backup and --restore") fmt.Println() - fmt.Println("This mode will send the backup log to the developer for debugging.") - fmt.Println("\033[33mIf your log contains personal or sensitive information, it will be shared.\033[0m") - fmt.Println() - - accepted, err := promptYesNoSupport(reader, "Do you accept and continue? [y/N]: ") - if err != nil { - if ctx.Err() == context.Canceled { - bootstrap.Warning("Support mode interrupted by signal") - return false, true - } - bootstrap.Error("ERROR: %v", err) - return false, false - } - if !accepted { - bootstrap.Warning("Support mode aborted by user (consent not granted)") - return false, false - } - - fmt.Println() - fmt.Println("Before proceeding, you must have an open GitHub issue for this problem.") - fmt.Println("Emails without a corresponding GitHub issue will not be analyzed.") - fmt.Println() - - hasIssue, err := promptYesNoSupport(reader, "Do you confirm that you have already opened a GitHub issue? [y/N]: ") - if err != nil { - if ctx.Err() == context.Canceled { - bootstrap.Warning("Support mode interrupted by signal") - return false, true - } - bootstrap.Error("ERROR: %v", err) - return false, false - } - if !hasIssue { - bootstrap.Warning("Support mode aborted: please open a GitHub issue first") - return false, false - } - - // GitHub nickname - for { - fmt.Print("Enter your GitHub nickname: ") - line, err := reader.ReadString('\n') - if err != nil { - if ctx.Err() == context.Canceled { - bootstrap.Warning("Support mode interrupted by signal") - return false, true - } - bootstrap.Error("ERROR: Failed to read input: %v", err) - return false, false - } - nickname := strings.TrimSpace(line) - if nickname == "" { - fmt.Println("GitHub nickname cannot be empty. Please try again.") - continue - } - args.SupportGitHubUser = nickname - break - } - - // GitHub issue number - for { - fmt.Print("Enter the GitHub issue number in the format #1234: ") - line, err := reader.ReadString('\n') - if err != nil { - if ctx.Err() == context.Canceled { - bootstrap.Warning("Support mode interrupted by signal") - return false, true - } - bootstrap.Error("ERROR: Failed to read input: %v", err) - return false, false - } - issue := strings.TrimSpace(line) - if issue == "" { - fmt.Println("Issue number cannot be empty. Please try again.") - continue - } - if !strings.HasPrefix(issue, "#") || len(issue) < 2 { - fmt.Println("Issue must start with '#' and contain a numeric ID, for example: #1234.") - continue - } - if _, err := strconv.Atoi(issue[1:]); err != nil { - fmt.Println("Issue must be in the format #1234 with a numeric ID. Please try again.") - continue - } - args.SupportIssueID = issue - break - } - - fmt.Println() - fmt.Println("Support mode confirmed.") - fmt.Println("The backup will run in DEBUG mode and a support email with the full log will be sent to github-support@tis24.it at the end.") - fmt.Println() - - return true, false -} - -func promptYesNoSupport(reader *bufio.Reader, prompt string) (bool, error) { - for { - fmt.Print(prompt) - line, err := reader.ReadString('\n') - if err != nil { - return false, err - } - answer := strings.TrimSpace(strings.ToLower(line)) - if answer == "y" || answer == "yes" { - return true, nil - } - if answer == "" || answer == "n" || answer == "no" { - return false, nil - } - fmt.Println("Please answer with 'y' or 'n'.") - } } // checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). diff --git a/cmd/proxsave/prompts.go b/cmd/proxsave/prompts.go index 65e1748..15b906a 100644 --- a/cmd/proxsave/prompts.go +++ b/cmd/proxsave/prompts.go @@ -3,18 +3,16 @@ package main import ( "bufio" "context" - "errors" "fmt" - "io" "os" "strings" + "github.com/tis24dev/proxsave/internal/input" "golang.org/x/term" ) var ( - errInteractiveAborted = errors.New("interactive input aborted") - errPromptInputClosed = errors.New("stdin closed") + errInteractiveAborted = input.ErrInputAborted ) func ensureInteractiveStdin() error { @@ -30,7 +28,7 @@ func promptYesNo(ctx context.Context, reader *bufio.Reader, question string, def return false, errInteractiveAborted } fmt.Print(question) - resp, err := readLineWithContext(ctx, reader) + resp, err := input.ReadLineWithContext(ctx, reader) if err != nil { return false, err } @@ -55,7 +53,7 @@ func promptNonEmpty(ctx context.Context, reader *bufio.Reader, question string) return "", errInteractiveAborted } fmt.Print(question) - resp, err := readLineWithContext(ctx, reader) + resp, err := input.ReadLineWithContext(ctx, reader) if err != nil { return "", err } @@ -66,43 +64,3 @@ func promptNonEmpty(ctx context.Context, reader *bufio.Reader, question string) fmt.Println("Value cannot be empty.") } } - -func readLineWithContext(ctx context.Context, reader *bufio.Reader) (string, error) { - type result struct { - line string - err error - } - ch := make(chan result, 1) - go func() { - line, err := reader.ReadString('\n') - ch <- result{line: line, err: mapPromptInputError(err)} - }() - select { - case <-ctx.Done(): - return "", errInteractiveAborted - case res := <-ch: - if res.err != nil { - if errors.Is(res.err, errPromptInputClosed) { - return "", errInteractiveAborted - } - return "", res.err - } - return res.line, nil - } -} - -func mapPromptInputError(err error) error { - if err == nil { - return nil - } - if errors.Is(err, io.EOF) { - return errPromptInputClosed - } - errStr := strings.ToLower(err.Error()) - if strings.Contains(errStr, "use of closed file") || - strings.Contains(errStr, "bad file descriptor") || - strings.Contains(errStr, "file already closed") { - return errPromptInputClosed - } - return err -} diff --git a/cmd/proxsave/prompts_test.go b/cmd/proxsave/prompts_test.go index 0d8ea47..08ddd93 100644 --- a/cmd/proxsave/prompts_test.go +++ b/cmd/proxsave/prompts_test.go @@ -7,6 +7,8 @@ import ( "os" "strings" "testing" + + "github.com/tis24dev/proxsave/internal/input" ) func TestPromptYesNo(t *testing.T) { @@ -64,17 +66,17 @@ func TestReadLineWithContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() reader := bufio.NewReader(strings.NewReader("ignored\n")) - if _, err := readLineWithContext(ctx, reader); !errors.Is(err, errInteractiveAborted) { - t.Fatalf("expected errInteractiveAborted, got %v", err) + if _, err := input.ReadLineWithContext(ctx, reader); !errors.Is(err, input.ErrInputAborted) { + t.Fatalf("expected ErrInputAborted, got %v", err) } } func TestReadLineWithContextSuccess(t *testing.T) { ctx := context.Background() reader := bufio.NewReader(strings.NewReader("hello\n")) - line, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - t.Fatalf("readLineWithContext error: %v", err) + t.Fatalf("ReadLineWithContext error: %v", err) } if line != "hello\n" { t.Fatalf("expected full line with newline, got %q", line) diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 2056a6b..8a33a0e 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -493,7 +493,7 @@ Next step: ./build/proxsave --dry-run ### Support Mode ```bash -# Run backup in support mode: force DEBUG logging and send log to developer +# Run in support mode: force DEBUG logging and send log to developer ./build/proxsave --support ``` @@ -524,7 +524,7 @@ Next step: ./build/proxsave --dry-run | Flag | Description | |------|-------------| -| `--support` | Run backup with DEBUG logging and email log to developer | +| `--support` | Run in support mode (force DEBUG logging and email log to developer). Available for the standard backup run and `--restore` | --- @@ -713,7 +713,7 @@ crontab -e | `--age-newkey` | - | Alias for `--newkey` | | `--decrypt` | - | Decrypt existing backup | | `--restore` | - | Restore from backup to system | -| `--support` | - | Run with DEBUG logging and email log | +| `--support` | - | Run in support mode (force DEBUG logging and email log). Available for the standard backup run and `--restore` | ### Common Command Patterns diff --git a/internal/backup/collector.go b/internal/backup/collector.go index 1e6a7b6..a209020 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -660,7 +660,7 @@ func (c *Collector) safeCopyFile(ctx context.Context, src, dest, description str if err := osSymlink(target, dest); err != nil { c.incFilesFailed() - return fmt.Errorf("symlink creation failed - source: %s, target: %s, absolute: %v: %w", + return fmt.Errorf("symlink creation failed - source: %s - target: %s - absolute: %v: %w", src, target, filepath.IsAbs(target), err) } diff --git a/internal/cli/args.go b/internal/cli/args.go index 45109f2..2b58e69 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -66,7 +66,7 @@ func Parse() *Args { "Perform a dry run (shorthand)") flag.BoolVar(&args.Support, "support", false, - "Run backup in support mode (force debug log level and send a support email with the attached log to github-support@tis24.it)") + "Run in support mode (force debug log level and send a support email with the attached log to github-support@tis24.it). Available for the standard backup run and --restore") flag.BoolVar(&args.ForceCLI, "cli", false, "Use CLI prompts instead of TUI for interactive workflows (works with --install/--new-install/--newkey/--decrypt/--restore)") diff --git a/internal/input/input.go b/internal/input/input.go new file mode 100644 index 0000000..7dafd2d --- /dev/null +++ b/internal/input/input.go @@ -0,0 +1,82 @@ +package input + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "strings" +) + +// ErrInputAborted signals that interactive input was interrupted (typically via Ctrl+C +// causing context cancellation and/or stdin closure). +// +// Callers should translate this into the appropriate workflow-level abort error. +var ErrInputAborted = errors.New("input aborted") + +// MapInputError normalizes common stdin errors (EOF/closed fd) into ErrInputAborted. +func MapInputError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) { + return ErrInputAborted + } + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "use of closed file") || + strings.Contains(errStr, "bad file descriptor") || + strings.Contains(errStr, "file already closed") { + return ErrInputAborted + } + return err +} + +// ReadLineWithContext reads a single line and supports cancellation. On ctx cancellation +// or stdin closure it returns ErrInputAborted. +func ReadLineWithContext(ctx context.Context, reader *bufio.Reader) (string, error) { + if ctx == nil { + ctx = context.Background() + } + type result struct { + line string + err error + } + ch := make(chan result, 1) + go func() { + line, err := reader.ReadString('\n') + ch <- result{line: line, err: MapInputError(err)} + }() + select { + case <-ctx.Done(): + return "", ErrInputAborted + case res := <-ch: + return res.line, res.err + } +} + +// ReadPasswordWithContext reads a password (no echo) and supports cancellation. On ctx +// cancellation or stdin closure it returns ErrInputAborted. +func ReadPasswordWithContext(ctx context.Context, readPassword func(int) ([]byte, error), fd int) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + if readPassword == nil { + return nil, errors.New("readPassword function is nil") + } + type result struct { + b []byte + err error + } + ch := make(chan result, 1) + go func() { + b, err := readPassword(fd) + ch <- result{b: b, err: MapInputError(err)} + }() + select { + case <-ctx.Done(): + return nil, ErrInputAborted + case res := <-ch: + return res.b, res.err + } +} diff --git a/internal/input/input_test.go b/internal/input/input_test.go new file mode 100644 index 0000000..9369375 --- /dev/null +++ b/internal/input/input_test.go @@ -0,0 +1,148 @@ +package input + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" +) + +func TestMapInputError(t *testing.T) { + if MapInputError(nil) != nil { + t.Fatalf("expected nil") + } + if !errors.Is(MapInputError(io.EOF), ErrInputAborted) { + t.Fatalf("expected ErrInputAborted for EOF") + } + if !errors.Is(MapInputError(os.ErrClosed), ErrInputAborted) { + t.Fatalf("expected ErrInputAborted for ErrClosed") + } + + for _, msg := range []string{ + "use of closed file", + "bad file descriptor", + "file already closed", + "Use Of Closed File", // case-insensitive + } { + if !errors.Is(MapInputError(errors.New(msg)), ErrInputAborted) { + t.Fatalf("expected ErrInputAborted for %q", msg) + } + } + + sentinel := errors.New("some other error") + if MapInputError(sentinel) != sentinel { + t.Fatalf("expected passthrough for non-mapped errors") + } +} + +func TestReadLineWithContext_ReturnsLine(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("hello\n")) + got, err := ReadLineWithContext(context.Background(), reader) + if err != nil { + t.Fatalf("ReadLineWithContext error: %v", err) + } + if got != "hello\n" { + t.Fatalf("got=%q; want %q", got, "hello\n") + } +} + +func TestReadLineWithContext_NilContextWorks(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("hello\n")) + got, err := ReadLineWithContext(nil, reader) + if err != nil { + t.Fatalf("ReadLineWithContext error: %v", err) + } + if got != "hello\n" { + t.Fatalf("got=%q; want %q", got, "hello\n") + } +} + +func TestReadLineWithContext_CancelledReturnsAborted(t *testing.T) { + pr, pw := io.Pipe() + defer pr.Close() + defer pw.Close() + + reader := bufio.NewReader(pr) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + var err error + go func() { + defer close(done) + _, err = ReadLineWithContext(ctx, reader) + }() + + cancel() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatalf("ReadLineWithContext did not return after cancellation") + } + if !errors.Is(err, ErrInputAborted) { + t.Fatalf("err=%v; want %v", err, ErrInputAborted) + } + + // Ensure the read goroutine unblocks and exits. + _ = pw.Close() +} + +func TestReadPasswordWithContext_NilReadPasswordErrors(t *testing.T) { + _, err := ReadPasswordWithContext(context.Background(), nil, 0) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestReadPasswordWithContext_ReturnsBytes(t *testing.T) { + readPassword := func(fd int) ([]byte, error) { + if fd != 123 { + t.Fatalf("fd=%d; want 123", fd) + } + return []byte("secret"), nil + } + got, err := ReadPasswordWithContext(context.Background(), readPassword, 123) + if err != nil { + t.Fatalf("ReadPasswordWithContext error: %v", err) + } + if string(got) != "secret" { + t.Fatalf("got=%q; want %q", string(got), "secret") + } +} + +func TestReadPasswordWithContext_NilContextWorks(t *testing.T) { + readPassword := func(fd int) ([]byte, error) { + return []byte("secret"), nil + } + got, err := ReadPasswordWithContext(nil, readPassword, 0) + if err != nil { + t.Fatalf("ReadPasswordWithContext error: %v", err) + } + if string(got) != "secret" { + t.Fatalf("got=%q; want %q", string(got), "secret") + } +} + +func TestReadPasswordWithContext_CancelledReturnsAborted(t *testing.T) { + unblock := make(chan struct{}) + readPassword := func(fd int) ([]byte, error) { + <-unblock + return []byte("secret"), nil + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := ReadPasswordWithContext(ctx, readPassword, 0) + close(unblock) // ensure goroutine can exit + if got != nil { + t.Fatalf("expected nil bytes on cancel") + } + if !errors.Is(err, ErrInputAborted) { + t.Fatalf("err=%v; want %v", err, ErrInputAborted) + } +} + diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 198d2b9..abf2e49 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=1146235 +pid=192633 host=pve -time=2026-01-11T12:27:12+01:00 +time=2026-01-16T16:25:03+01:00 diff --git a/internal/orchestrator/additional_helpers_test.go b/internal/orchestrator/additional_helpers_test.go index 83e6eaa..3bda536 100644 --- a/internal/orchestrator/additional_helpers_test.go +++ b/internal/orchestrator/additional_helpers_test.go @@ -1148,7 +1148,7 @@ func TestShowRestoreModeMenu(t *testing.T) { _, _ = w.WriteString(tt.input) _ = w.Close() os.Stdin = r - mode, err := ShowRestoreModeMenu(logger, SystemTypePVE) + mode, err := ShowRestoreModeMenu(context.Background(), logger, SystemTypePVE) if err != nil { t.Fatalf("ShowRestoreModeMenu error: %v", err) } @@ -1162,7 +1162,7 @@ func TestShowRestoreModeMenu(t *testing.T) { _, _ = w.WriteString("0\n") _ = w.Close() os.Stdin = r - if _, err := ShowRestoreModeMenu(logger, SystemTypePVE); err == nil { + if _, err := ShowRestoreModeMenu(context.Background(), logger, SystemTypePVE); err == nil { t.Fatalf("expected cancel error") } } @@ -1183,7 +1183,7 @@ func TestShowCategorySelectionMenu(t *testing.T) { _, _ = w.WriteString("a\nc\n") _ = w.Close() os.Stdin = r - cats, err := ShowCategorySelectionMenu(logger, available, SystemTypePVE) + cats, err := ShowCategorySelectionMenu(context.Background(), logger, available, SystemTypePVE) if err != nil { t.Fatalf("ShowCategorySelectionMenu error: %v", err) } @@ -1196,7 +1196,7 @@ func TestShowCategorySelectionMenu(t *testing.T) { _, _ = w.WriteString("1\n3\nc\n") _ = w.Close() os.Stdin = r - cats, err = ShowCategorySelectionMenu(logger, available, SystemTypePVE) + cats, err = ShowCategorySelectionMenu(context.Background(), logger, available, SystemTypePVE) if err != nil { t.Fatalf("ShowCategorySelectionMenu toggle error: %v", err) } @@ -1209,7 +1209,7 @@ func TestShowCategorySelectionMenu(t *testing.T) { _, _ = w.WriteString("0\n") _ = w.Close() os.Stdin = r - if _, err := ShowCategorySelectionMenu(logger, available, SystemTypePVE); err == nil { + if _, err := ShowCategorySelectionMenu(context.Background(), logger, available, SystemTypePVE); err == nil { t.Fatalf("expected cancel error") } } diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index 1d9bf4d..a4669de 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -25,60 +25,65 @@ type decryptPathOption struct { // buildDecryptPathOptions builds the list of available backup sources // (primary, secondary, cloud) from the loaded configuration. -func buildDecryptPathOptions(cfg *config.Config) []decryptPathOption { - options := make([]decryptPathOption, 0, 3) +func buildDecryptPathOptions(cfg *config.Config, logger *logging.Logger) (options []decryptPathOption) { + if cfg == nil { + logging.DebugStep(logger, "build backup source options", "skip (cfg=nil)") + return nil + } + done := logging.DebugStart(logger, "build backup source options", "secondary_enabled=%v cloud_enabled=%v", cfg.SecondaryEnabled, cfg.CloudEnabled) + defer func() { done(nil) }() + options = make([]decryptPathOption, 0, 3) if clean := strings.TrimSpace(cfg.BackupPath); clean != "" { + logging.DebugStep(logger, "build backup source options", "add local path=%q", clean) options = append(options, decryptPathOption{ Label: "Local backups", Path: clean, }) + } else { + logging.DebugStep(logger, "build backup source options", "skip local (empty)") } - if cfg.SecondaryEnabled { - if clean := strings.TrimSpace(cfg.SecondaryPath); clean != "" { - options = append(options, decryptPathOption{ - Label: "Secondary backups", - Path: clean, - }) - } + if clean := strings.TrimSpace(cfg.SecondaryPath); clean != "" { + logging.DebugStep(logger, "build backup source options", "add secondary path=%q", clean) + options = append(options, decryptPathOption{ + Label: "Secondary backups", + Path: clean, + }) + } else if cfg.SecondaryEnabled { + logging.DebugStep(logger, "build backup source options", "skip secondary (enabled but path empty)") + } else { + logging.DebugStep(logger, "build backup source options", "skip secondary (path empty)") } - if cfg.CloudEnabled { + if strings.TrimSpace(cfg.CloudRemote) != "" || strings.TrimSpace(cfg.CloudRemotePath) != "" { cloudRoot := buildCloudRemotePath(cfg.CloudRemote, cfg.CloudRemotePath) + logging.DebugStep(logger, "build backup source options", "cloud root=%q", cloudRoot) if isRcloneRemote(cloudRoot) { - // rclone remote (remote:path[/prefix]) - // Pre-scan: verify backups exist before adding option - scanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - candidates, err := discoverRcloneBackups(scanCtx, cloudRoot, nil) - if err == nil && len(candidates) > 0 { - options = append(options, decryptPathOption{ - Label: "Cloud backups (rclone)", - Path: cloudRoot, - IsRclone: true, - }) - } + options = append(options, decryptPathOption{ + Label: "Cloud backups (rclone)", + Path: cloudRoot, + IsRclone: true, + }) } else if isLocalFilesystemPath(cloudRoot) { - // Local filesystem mount - // Pre-scan: verify backups exist before adding option - candidates, err := discoverBackupCandidates(nil, cloudRoot) - if err == nil && len(candidates) > 0 { - options = append(options, decryptPathOption{ - Label: "Cloud backups", - Path: cloudRoot, - IsRclone: false, - }) - } + options = append(options, decryptPathOption{ + Label: "Cloud backups", + Path: cloudRoot, + IsRclone: false, + }) + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud (unrecognized root)") } + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud (not configured)") } + logging.DebugStep(logger, "build backup source options", "final options=%d", len(options)) return options } -// discoverRcloneBackups lists backup bundles from an rclone remote and returns -// decrypt candidates backed by that remote. +// discoverRcloneBackups lists backup candidates from an rclone remote and returns +// decrypt candidates backed by that remote (bundles and raw archives). func discoverRcloneBackups(ctx context.Context, remotePath string, logger *logging.Logger) (candidates []*decryptCandidate, err error) { done := logging.DebugStart(logger, "discover rclone backups", "remote=%s", remotePath) defer func() { done(err) }() @@ -89,6 +94,7 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi } logging.DebugStep(logger, "discover rclone backups", "listing remote: %s", fullPath) + logging.DebugStep(logger, "discover rclone backups", "filters=bundle.tar and raw .metadata") logDebug(logger, "Cloud (rclone): listing backups under %s", fullPath) logDebug(logger, "Cloud (rclone): executing: rclone lsf %s", fullPath) @@ -98,57 +104,137 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi if err != nil { return nil, fmt.Errorf("failed to list rclone remote %s: %w (output: %s)", fullPath, err, string(output)) } + logging.DebugStep(logger, "discover rclone backups", "rclone lsf output bytes=%d", len(output)) candidates = make([]*decryptCandidate, 0) lines := strings.Split(string(output), "\n") - logDebug(logger, "Cloud (rclone): scanned %d entries from rclone lsf output", len(lines)) + totalEntries := len(lines) + emptyEntries := 0 + nonCandidateEntries := 0 + manifestErrors := 0 + logDebug(logger, "Cloud (rclone): scanned %d entries from rclone lsf output", totalEntries) + snapshot := make(map[string]struct{}, len(lines)) + ordered := make([]string, 0, len(lines)) for _, line := range lines { filename := strings.TrimSpace(line) if filename == "" { + emptyEntries++ + continue + } + if _, ok := snapshot[filename]; ok { continue } + snapshot[filename] = struct{}{} + ordered = append(ordered, filename) + } + + joinRemote := func(base, rel string) string { + remoteFile := base + if !strings.HasSuffix(remoteFile, ":") && !strings.HasSuffix(remoteFile, "/") { + remoteFile += "/" + } + return remoteFile + rel + } + for _, filename := range ordered { // Only process bundle files (both plain and age-encrypted) // Valid patterns: // - *.tar.{gz|xz|zst}.bundle.tar (plain bundle) // - *.tar.{gz|xz|zst}.age.bundle.tar (age-encrypted bundle) - if !strings.Contains(filename, ".bundle.tar") { - continue - } + switch { + case strings.HasSuffix(filename, ".bundle.tar"): + remoteFile := joinRemote(fullPath, filename) + manifest, err := inspectRcloneBundleManifest(ctx, remoteFile, logger) + if err != nil { + manifestErrors++ + logWarning(logger, "Skipping rclone bundle %s: %v", filename, err) + continue + } - // Must contain backup indicator in filename - isBackup := strings.Contains(filename, "-backup-") || strings.HasPrefix(filename, "proxmox-backup-") - if !isBackup { - logDebug(logger, "Skipping non-backup bundle: %s", filename) - continue - } + displayBase := filepath.Base(manifest.ArchivePath) + if strings.TrimSpace(displayBase) == "" { + displayBase = filepath.Base(filename) + } + candidates = append(candidates, &decryptCandidate{ + Manifest: manifest, + Source: sourceBundle, + BundlePath: remoteFile, + DisplayBase: displayBase, + IsRclone: true, + }) + logDebug(logger, "Cloud (rclone): accepted backup bundle: %s", filename) - // Join root reference and filename with a single separator. - remoteFile := fullPath - if !strings.HasSuffix(remoteFile, ":") && !strings.HasSuffix(remoteFile, "/") { - remoteFile += "/" - } - remoteFile += filename + case strings.HasSuffix(filename, ".metadata"): + // Raw backups: archive + .metadata (+ optional .sha256). + archiveName := strings.TrimSuffix(filename, ".metadata") + if !strings.Contains(archiveName, ".tar") { + nonCandidateEntries++ + continue + } + if _, ok := snapshot[archiveName]; !ok { + nonCandidateEntries++ + continue + } - manifest, err := inspectRcloneBundleManifest(ctx, remoteFile, logger) - if err != nil { - logWarning(logger, "Skipping rclone bundle %s: %v", filename, err) - continue - } + remoteArchive := joinRemote(fullPath, archiveName) + remoteMetadata := joinRemote(fullPath, filename) + remoteChecksum := "" + if _, ok := snapshot[archiveName+".sha256"]; ok { + remoteChecksum = joinRemote(fullPath, archiveName+".sha256") + } - candidates = append(candidates, &decryptCandidate{ - Manifest: manifest, - Source: sourceBundle, - BundlePath: remoteFile, - DisplayBase: filepath.Base(manifest.ArchivePath), - IsRclone: true, - }) - logDebug(logger, "Cloud (rclone): accepted backup bundle: %s", filename) + manifest, err := inspectRcloneMetadataManifest(ctx, remoteMetadata, remoteArchive, logger) + if err != nil { + manifestErrors++ + logWarning(logger, "Skipping rclone metadata %s: %v", filename, err) + continue + } + displayBase := filepath.Base(manifest.ArchivePath) + if strings.TrimSpace(displayBase) == "" { + displayBase = filepath.Base(archiveName) + } + candidates = append(candidates, &decryptCandidate{ + Manifest: manifest, + Source: sourceRaw, + RawArchivePath: remoteArchive, + RawMetadataPath: remoteMetadata, + RawChecksumPath: remoteChecksum, + DisplayBase: displayBase, + IsRclone: true, + }) + default: + nonCandidateEntries++ + } } - logDebug(logger, "Cloud (rclone): scanned %d files, found %d valid backup bundles", len(lines), len(candidates)) + sort.SliceStable(candidates, func(i, j int) bool { + a := candidates[i] + b := candidates[j] + if a == nil || a.Manifest == nil { + return false + } + if b == nil || b.Manifest == nil { + return true + } + if !a.Manifest.CreatedAt.Equal(b.Manifest.CreatedAt) { + return a.Manifest.CreatedAt.After(b.Manifest.CreatedAt) + } + return a.DisplayBase < b.DisplayBase + }) + + logging.DebugStep( + logger, + "discover rclone backups", + "summary entries=%d empty=%d non_candidate=%d manifest_errors=%d accepted=%d", + totalEntries, + emptyEntries, + nonCandidateEntries, + manifestErrors, + len(candidates), + ) + logDebug(logger, "Cloud (rclone): scanned %d entries, found %d valid backup candidate(s)", len(lines), len(candidates)) logDebug(logger, "Cloud (rclone): discovered %d bundle candidate(s) in %s", len(candidates), fullPath) return candidates, nil @@ -167,21 +253,36 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ candidates = make([]*decryptCandidate, 0) rawBases := make(map[string]struct{}) + filesSeen := 0 + dirsSkipped := 0 + bundleSeen := 0 + bundleManifestErrors := 0 + metadataSeen := 0 + metadataDuplicate := 0 + metadataMissingArchive := 0 + metadataManifestErrors := 0 + checksumMissing := 0 for _, entry := range entries { if entry.IsDir() { + dirsSkipped++ continue } + filesSeen++ name := entry.Name() fullPath := filepath.Join(root, name) switch { case strings.HasSuffix(name, ".bundle.tar"): + bundleSeen++ + logging.DebugStep(logger, "discover backup candidates", "inspect bundle manifest: %s", name) manifest, err := inspectBundleManifest(fullPath) if err != nil { + bundleManifestErrors++ logWarning(logger, "Skipping bundle %s: %v", name, err) continue } + logging.DebugStep(logger, "discover backup candidates", "bundle accepted: %s created_at=%s", name, manifest.CreatedAt.Format(time.RFC3339)) candidates = append(candidates, &decryptCandidate{ Manifest: manifest, Source: sourceBundle, @@ -189,27 +290,35 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ DisplayBase: filepath.Base(manifest.ArchivePath), }) case strings.HasSuffix(name, ".metadata"): + metadataSeen++ baseName := strings.TrimSuffix(name, ".metadata") if _, ok := rawBases[baseName]; ok { + metadataDuplicate++ continue } archivePath := filepath.Join(root, baseName) if _, err := restoreFS.Stat(archivePath); err != nil { + metadataMissingArchive++ + logging.DebugStep(logger, "discover backup candidates", "skip metadata %s (missing archive %s)", name, baseName) continue } checksumPath := archivePath + ".sha256" hasChecksum := true if _, err := restoreFS.Stat(checksumPath); err != nil { // Checksum missing - allow but warn + checksumMissing++ logWarning(logger, "Backup %s is missing .sha256 checksum file", baseName) checksumPath = "" hasChecksum = false } + logging.DebugStep(logger, "discover backup candidates", "load manifest: %s", name) manifest, err := backup.LoadManifest(fullPath) if err != nil { + metadataManifestErrors++ logWarning(logger, "Skipping metadata %s: %v", name, err) continue } + logging.DebugStep(logger, "discover backup candidates", "raw candidate accepted: %s created_at=%s", name, manifest.CreatedAt.Format(time.RFC3339)) // If checksum is missing from both file and manifest, warn user if !hasChecksum && manifest.SHA256 == "" { @@ -232,6 +341,22 @@ func discoverBackupCandidates(logger *logging.Logger, root string) (candidates [ return candidates[i].Manifest.CreatedAt.After(candidates[j].Manifest.CreatedAt) }) + logging.DebugStep( + logger, + "discover backup candidates", + "summary entries=%d files=%d dirs=%d bundles=%d bundle_manifest_errors=%d metadata=%d metadata_duplicate=%d metadata_missing_archive=%d metadata_manifest_errors=%d checksum_missing=%d candidates=%d", + len(entries), + filesSeen, + dirsSkipped, + bundleSeen, + bundleManifestErrors, + metadataSeen, + metadataDuplicate, + metadataMissingArchive, + metadataManifestErrors, + checksumMissing, + len(candidates), + ) return candidates, nil } diff --git a/internal/orchestrator/backup_sources_test.go b/internal/orchestrator/backup_sources_test.go index 07c3613..96ac581 100644 --- a/internal/orchestrator/backup_sources_test.go +++ b/internal/orchestrator/backup_sources_test.go @@ -122,11 +122,9 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "gdrive" cfg.CloudRemotePath = "pbs-backups/server1" - opts := buildDecryptPathOptions(cfg) - // With pre-scan enabled, cloud option is only shown if backups exist - // Since no actual backups exist in test environment, expect only local + secondary - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary, cloud hidden due to no backups)", len(opts)) + opts := buildDecryptPathOptions(cfg, nil) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } // Verify local and secondary are present if opts[0].Path != "/local" { @@ -135,6 +133,9 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { if opts[1].Path != "/secondary" { t.Fatalf("opts[1].Path = %q; want /secondary", opts[1].Path) } + if opts[2].IsRclone != true { + t.Fatalf("opts[2].IsRclone = %v; want true", opts[2].IsRclone) + } }) t.Run("rclone remote with base path and extra prefix", func(t *testing.T) { @@ -143,11 +144,9 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "gdrive:pbs-backups" cfg.CloudRemotePath = "server1" - opts := buildDecryptPathOptions(cfg) - // With pre-scan enabled, cloud option is only shown if backups exist - // Since no actual backups exist in test environment, expect only local + secondary - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary, cloud hidden due to no backups)", len(opts)) + opts := buildDecryptPathOptions(cfg, nil) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) @@ -157,11 +156,9 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "/mnt/cloud/backups" cfg.CloudRemotePath = "server1" - opts := buildDecryptPathOptions(cfg) - // With pre-scan enabled, cloud option is only shown if backups exist - // Since no actual backups exist in test environment, expect only local + secondary - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary, cloud hidden due to no backups)", len(opts)) + opts := buildDecryptPathOptions(cfg, nil) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) @@ -170,9 +167,9 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudEnabled = false cfg.CloudRemote = "gdrive:pbs-backups" - opts := buildDecryptPathOptions(cfg) - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary)", len(opts)) + opts := buildDecryptPathOptions(cfg, nil) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) } @@ -187,11 +184,9 @@ func TestBuildDecryptPathOptions_FullConfigOrder(t *testing.T) { CloudRemotePath: "pbs-backups/server1", } - opts := buildDecryptPathOptions(cfg) - // With pre-scan enabled, cloud option is only shown if backups exist - // Since no actual backups exist in test environment, expect only local + secondary - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary, cloud hidden due to no backups)", len(opts)) + opts := buildDecryptPathOptions(cfg, nil) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } if opts[0].Label != "Local backups" || opts[0].Path != "/local" { @@ -200,29 +195,8 @@ func TestBuildDecryptPathOptions_FullConfigOrder(t *testing.T) { if opts[1].Label != "Secondary backups" || opts[1].Path != "/secondary" { t.Fatalf("opts[1] = %#v; want Label=Secondary backups, Path=/secondary", opts[1]) } -} - -func TestDiscoverRcloneBackups_ParseFilenames(t *testing.T) { - // Test the filename filtering logic (independent of rclone invocation) - testFiles := []string{ - "backup-20250115.bundle.tar", - "backup-20250114.bundle.tar", - "backup-20250113.tar.xz", // Should be ignored (not .bundle.tar) - "log-20250115.log", // Should be ignored - "backup-20250112.bundle.tar.age", // Should be ignored (has .age extension) - } - - expectedCount := 2 // Only the two .bundle.tar files - - count := 0 - for _, filename := range testFiles { - if strings.HasSuffix(filename, ".bundle.tar") { - count++ - } - } - - if count != expectedCount { - t.Errorf("Expected %d .bundle.tar files, got %d", expectedCount, count) + if opts[2].Label != "Cloud backups (rclone)" || opts[2].Path != "gdrive:pbs-backups/server1" || !opts[2].IsRclone { + t.Fatalf("opts[2] = %#v; want Label=Cloud backups (rclone), Path=gdrive:pbs-backups/server1, IsRclone=true", opts[2]) } } @@ -252,6 +226,208 @@ func TestDiscoverRcloneBackups_ListsAndParsesBundles(t *testing.T) { } } +func TestDiscoverRcloneBackups_IncludesRawMetadata(t *testing.T) { + tmpDir := t.TempDir() + + manifest := backup.Manifest{ + ArchivePath: "/var/backups/node-backup-20251205.tar.xz", + ProxmoxType: "pve", + ProxmoxVersion: "8.1", + CreatedAt: time.Date(2025, 12, 5, 12, 0, 0, 0, time.UTC), + EncryptionMode: "none", + } + metaBytes, err := json.Marshal(&manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + metadataPath := filepath.Join(tmpDir, "node-backup-20251205.tar.xz.metadata") + if err := os.WriteFile(metadataPath, metaBytes, 0o600); err != nil { + t.Fatalf("write metadata: %v", err) + } + + scriptPath := filepath.Join(tmpDir, "rclone") + script := `#!/bin/sh +subcmd="$1" +case "$subcmd" in + lsf) + printf 'node-backup-20251205.tar.xz\n' + printf 'node-backup-20251205.tar.xz.metadata\n' + ;; + cat) + cat "$METADATA_PATH" + ;; + *) + echo "unexpected subcommand: $subcmd" >&2 + exit 1 + ;; +esac +` + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + + if err := os.Setenv("METADATA_PATH", metadataPath); err != nil { + t.Fatalf("set METADATA_PATH: %v", err) + } + defer os.Unsetenv("METADATA_PATH") + + ctx := context.Background() + candidates, err := discoverRcloneBackups(ctx, "gdrive:pbs-backups/server1", nil) + if err != nil { + t.Fatalf("discoverRcloneBackups() error = %v", err) + } + if len(candidates) != 1 { + t.Fatalf("discoverRcloneBackups() returned %d candidates; want 1", len(candidates)) + } + cand := candidates[0] + if cand.Source != sourceRaw { + t.Fatalf("Source = %v; want sourceRaw", cand.Source) + } + if !cand.IsRclone { + t.Fatalf("IsRclone = false; want true") + } + if cand.Manifest == nil { + t.Fatal("Manifest is nil") + } + if cand.Manifest.ArchivePath != manifest.ArchivePath { + t.Fatalf("ArchivePath = %q; want %q", cand.Manifest.ArchivePath, manifest.ArchivePath) + } + if !strings.HasSuffix(cand.RawArchivePath, "node-backup-20251205.tar.xz") { + t.Fatalf("RawArchivePath = %q; want to end with archive name", cand.RawArchivePath) + } + if !strings.HasSuffix(cand.RawMetadataPath, "node-backup-20251205.tar.xz.metadata") { + t.Fatalf("RawMetadataPath = %q; want to end with metadata name", cand.RawMetadataPath) + } +} + +func TestDiscoverRcloneBackups_MixedCandidatesSortedByCreatedAt(t *testing.T) { + tmpDir := t.TempDir() + + // 1) Raw candidate (newest) + rawNewestArchive := filepath.Join(tmpDir, "raw-newest.tar.xz") + rawNewestMeta := filepath.Join(tmpDir, "raw-newest.tar.xz.metadata") + rawNewest := backup.Manifest{ + ArchivePath: "/var/backups/raw-newest.tar.xz", + CreatedAt: time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), + EncryptionMode: "none", + ProxmoxType: "pve", + } + rawNewestData, _ := json.Marshal(&rawNewest) + if err := os.WriteFile(rawNewestMeta, rawNewestData, 0o600); err != nil { + t.Fatalf("write raw newest metadata: %v", err) + } + + // 2) Bundle candidate (middle) + bundlePath := filepath.Join(tmpDir, "bundle-mid.tar.xz.bundle.tar") + bundleManifest := backup.Manifest{ + ArchivePath: "/var/backups/bundle-mid.tar.xz", + CreatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + EncryptionMode: "age", + ProxmoxType: "pve", + } + f, err := os.Create(bundlePath) + if err != nil { + t.Fatalf("create bundle: %v", err) + } + tw := tar.NewWriter(f) + bData, _ := json.Marshal(&bundleManifest) + if err := tw.WriteHeader(&tar.Header{Name: "backup/bundle-mid.metadata", Mode: 0o600, Size: int64(len(bData))}); err != nil { + t.Fatalf("write bundle header: %v", err) + } + if _, err := tw.Write(bData); err != nil { + t.Fatalf("write bundle body: %v", err) + } + _ = tw.Close() + _ = f.Close() + + // 3) Raw candidate (oldest, with ArchivePath empty to exercise fallback) + rawOldArchive := filepath.Join(tmpDir, "raw-old.tar.xz") + rawOldMeta := filepath.Join(tmpDir, "raw-old.tar.xz.metadata") + rawOld := backup.Manifest{ + ArchivePath: "", + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + EncryptionMode: "none", + ProxmoxType: "pve", + } + rawOldData, _ := json.Marshal(&rawOld) + if err := os.WriteFile(rawOldMeta, rawOldData, 0o600); err != nil { + t.Fatalf("write raw old metadata: %v", err) + } + + // Fake rclone that supports lsf + cat for the above files. + scriptPath := filepath.Join(tmpDir, "rclone") + script := `#!/bin/sh +subcmd="$1" +target="$2" +case "$subcmd" in + lsf) + printf 'raw-newest.tar.xz\n' + printf 'raw-newest.tar.xz.metadata\n' + printf 'bundle-mid.tar.xz.bundle.tar\n' + printf 'raw-old.tar.xz\n' + printf 'raw-old.tar.xz.metadata\n' + ;; + cat) + case "$target" in + *bundle-mid.tar.xz.bundle.tar) cat "$BUNDLE_PATH" ;; + *raw-newest.tar.xz.metadata) cat "$RAW_NEWEST_META" ;; + *raw-old.tar.xz.metadata) cat "$RAW_OLD_META" ;; + *) echo "unexpected cat target: $target" >&2; exit 1 ;; + esac + ;; + *) + echo "unexpected subcommand: $subcmd" >&2 + exit 1 + ;; +esac +` + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + _ = os.Setenv("BUNDLE_PATH", bundlePath) + _ = os.Setenv("RAW_NEWEST_META", rawNewestMeta) + _ = os.Setenv("RAW_OLD_META", rawOldMeta) + defer os.Unsetenv("BUNDLE_PATH") + defer os.Unsetenv("RAW_NEWEST_META") + defer os.Unsetenv("RAW_OLD_META") + + // Ensure archives appear in lsf snapshot; their content is not fetched. + _ = os.WriteFile(rawNewestArchive, []byte("x"), 0o600) + _ = os.WriteFile(rawOldArchive, []byte("x"), 0o600) + + candidates, err := discoverRcloneBackups(context.Background(), "gdrive:backups", nil) + if err != nil { + t.Fatalf("discoverRcloneBackups error: %v", err) + } + if len(candidates) != 3 { + t.Fatalf("candidates=%d; want 3", len(candidates)) + } + if candidates[0].Manifest.CreatedAt != rawNewest.CreatedAt { + t.Fatalf("candidates[0].CreatedAt=%s; want %s", candidates[0].Manifest.CreatedAt, rawNewest.CreatedAt) + } + if candidates[1].Manifest.CreatedAt != bundleManifest.CreatedAt { + t.Fatalf("candidates[1].CreatedAt=%s; want %s", candidates[1].Manifest.CreatedAt, bundleManifest.CreatedAt) + } + if candidates[2].Manifest.CreatedAt != rawOld.CreatedAt { + t.Fatalf("candidates[2].CreatedAt=%s; want %s", candidates[2].Manifest.CreatedAt, rawOld.CreatedAt) + } + if candidates[2].Manifest.ArchivePath != "gdrive:backups/raw-old.tar.xz" { + t.Fatalf("raw-old ArchivePath=%q; want fallback to remote archive", candidates[2].Manifest.ArchivePath) + } +} + func TestDiscoverRcloneBackups_AllowsNilLogger(t *testing.T) { ctx := context.Background() manifest, cleanup := setupFakeRcloneListAndCat(t) diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go index 8c69ad5..a1614da 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -15,10 +15,12 @@ import ( "path/filepath" "strconv" "strings" + "time" "filippo.io/age" "github.com/tis24dev/proxsave/internal/backup" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -77,6 +79,14 @@ func RunDecryptWorkflowWithDeps(ctx context.Context, deps *Deps, version string) } done := logging.DebugStart(logger, "decrypt workflow", "version=%s", version) defer func() { done(err) }() + defer func() { + if err == nil { + return + } + if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) { + err = ErrDecryptAborted + } + }() reader := bufio.NewReader(os.Stdin) _, prepared, err := prepareDecryptedBackup(ctx, reader, cfg, logger, version, true) @@ -175,7 +185,7 @@ func RunDecryptWorkflow(ctx context.Context, cfg *config.Config, logger *logging func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, requireEncrypted bool) (candidate *decryptCandidate, err error) { done := logging.DebugStart(logger, "select backup candidate", "requireEncrypted=%v", requireEncrypted) defer func() { done(err) }() - pathOptions := buildDecryptPathOptions(cfg) + pathOptions := buildDecryptPathOptions(cfg, logger) if len(pathOptions) == 0 { return nil, fmt.Errorf("no backup paths configured in backup.env") } @@ -199,7 +209,7 @@ func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *conf logger.Debug("Backup source selected by user: label=%q path=%q isRclone=%v", option.Label, option.Path, option.IsRclone) } - logger.Info("Scanning %s for backup bundles...", option.Path) + logger.Info("Scanning %s for backups...", option.Path) // Handle rclone remotes differently from filesystem paths if option.IsRclone { @@ -232,7 +242,7 @@ func selectDecryptCandidate(ctx context.Context, reader *bufio.Reader, cfg *conf } } if len(candidates) == 0 { - logger.Warning("No backup bundles found in %s – removing from source list", option.Path) + logger.Warning("No backups found in %s – removing from source list", option.Path) if logger != nil { logger.Debug("Removing backup source %q (%s) due to empty candidate list", option.Label, option.Path) } @@ -285,11 +295,11 @@ func promptPathSelection(ctx context.Context, reader *bufio.Reader, options []de fmt.Println(" [0] Exit") fmt.Print("Choice: ") - input, err := readLineWithContext(ctx, reader) + choiceLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { return decryptPathOption{}, err } - trimmed := strings.TrimSpace(input) + trimmed := strings.TrimSpace(choiceLine) if trimmed == "0" { return decryptPathOption{}, ErrDecryptAborted } @@ -345,6 +355,7 @@ func inspectBundleManifest(bundlePath string) (*backup.Manifest, error) { func inspectRcloneBundleManifest(ctx context.Context, remotePath string, logger *logging.Logger) (manifest *backup.Manifest, err error) { done := logging.DebugStart(logger, "inspect rclone bundle manifest", "remote=%s", remotePath) defer func() { done(err) }() + logging.DebugStep(logger, "inspect rclone bundle manifest", "executing: rclone cat %s", remotePath) cmd := exec.CommandContext(ctx, "rclone", "cat", remotePath) stdout, err := cmd.StdoutPipe() if err != nil { @@ -403,6 +414,78 @@ func inspectRcloneBundleManifest(ctx context.Context, remotePath string, logger return manifest, nil } +// inspectRcloneMetadataManifest reads a sidecar metadata file from an rclone +// remote by streaming it through "rclone cat" and parsing it as either the +// JSON manifest format or the legacy KEY=VALUE format. +func inspectRcloneMetadataManifest(ctx context.Context, remoteMetadataPath, remoteArchivePath string, logger *logging.Logger) (manifest *backup.Manifest, err error) { + done := logging.DebugStart(logger, "inspect rclone metadata manifest", "remote=%s", remoteMetadataPath) + defer func() { done(err) }() + logging.DebugStep(logger, "inspect rclone metadata manifest", "executing: rclone cat %s", remoteMetadataPath) + + cmd := exec.CommandContext(ctx, "rclone", "cat", remoteMetadataPath) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("rclone cat %s failed: %w (output: %s)", remoteMetadataPath, err, strings.TrimSpace(string(output))) + } + data := bytes.TrimSpace(output) + if len(data) == 0 { + return nil, fmt.Errorf("metadata file is empty") + } + + var parsed backup.Manifest + if err := json.Unmarshal(data, &parsed); err == nil { + manifest = &parsed + if strings.TrimSpace(manifest.ArchivePath) == "" { + manifest.ArchivePath = remoteArchivePath + } + return manifest, nil + } + + // Legacy KEY=VALUE format (best-effort, without archive stat/checksum). + legacy := &backup.Manifest{ + ArchivePath: remoteArchivePath, + } + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "COMPRESSION_TYPE": + legacy.CompressionType = value + case "COMPRESSION_LEVEL": + if lvl, err := strconv.Atoi(value); err == nil { + legacy.CompressionLevel = lvl + } + case "PROXMOX_TYPE": + legacy.ProxmoxType = value + case "HOSTNAME": + legacy.Hostname = value + case "SCRIPT_VERSION": + legacy.ScriptVersion = value + case "ENCRYPTION_MODE": + legacy.EncryptionMode = value + } + } + if strings.TrimSpace(legacy.EncryptionMode) == "" { + if strings.HasSuffix(remoteArchivePath, ".age") { + legacy.EncryptionMode = "age" + } else { + legacy.EncryptionMode = "plain" + } + } + // Keep CreatedAt stable (zero) rather than guessing. + legacy.CreatedAt = time.Time{} + return legacy, nil +} + func promptCandidateSelection(ctx context.Context, reader *bufio.Reader, candidates []*decryptCandidate) (*decryptCandidate, error) { for { fmt.Println("\nAvailable backups:") @@ -419,11 +502,11 @@ func promptCandidateSelection(ctx context.Context, reader *bufio.Reader, candida fmt.Println(" [0] Exit") fmt.Print("Choice: ") - input, err := readLineWithContext(ctx, reader) + choiceLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { return nil, err } - trimmed := strings.TrimSpace(input) + trimmed := strings.TrimSpace(choiceLine) if trimmed == "0" { return nil, ErrDecryptAborted } @@ -447,11 +530,11 @@ func promptDestinationDir(ctx context.Context, reader *bufio.Reader, cfg *config } } fmt.Printf("\nEnter destination directory for decrypted bundle [press Enter to use %s]: ", defaultDir) - input, err := readLineWithContext(ctx, reader) + inputLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { return "", err } - trimmed := strings.TrimSpace(input) + trimmed := strings.TrimSpace(inputLine) if trimmed == "" { trimmed = defaultDir } @@ -540,10 +623,10 @@ func preparePlainBundle(ctx context.Context, reader *bufio.Reader, cand *decrypt switch cand.Source { case sourceBundle: logger.Info("Extracting bundle %s", filepath.Base(cand.BundlePath)) - staged, err = extractBundleToWorkdir(cand.BundlePath, workDir) + staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger) case sourceRaw: logger.Info("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath)) - staged, err = copyRawArtifactsToWorkdir(cand, workDir) + staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } @@ -649,7 +732,11 @@ func sanitizeBundleEntryName(name string) (string, error) { } func extractBundleToWorkdir(bundlePath, workDir string) (staged stagedFiles, err error) { - done := logging.DebugStart(logging.GetDefaultLogger(), "extract bundle", "bundle=%s workdir=%s", bundlePath, workDir) + return extractBundleToWorkdirWithLogger(bundlePath, workDir, nil) +} + +func extractBundleToWorkdirWithLogger(bundlePath, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { + done := logging.DebugStart(logger, "extract bundle", "bundle=%s workdir=%s", bundlePath, workDir) defer func() { done(err) }() file, err := restoreFS.Open(bundlePath) if err != nil { @@ -658,6 +745,7 @@ func extractBundleToWorkdir(bundlePath, workDir string) (staged stagedFiles, err defer file.Close() tr := tar.NewReader(file) + extracted := 0 for { hdr, err := tr.Next() @@ -693,38 +781,126 @@ func extractBundleToWorkdir(bundlePath, workDir string) (staged stagedFiles, err return stagedFiles{}, fmt.Errorf("write %s: %w", hdr.Name, err) } out.Close() + extracted++ switch { case strings.HasSuffix(target, ".metadata"): staged.MetadataPath = target + logging.DebugStep(logger, "extract bundle", "found metadata=%s", filepath.Base(target)) case strings.HasSuffix(target, ".sha256"): staged.ChecksumPath = target + logging.DebugStep(logger, "extract bundle", "found checksum=%s", filepath.Base(target)) default: staged.ArchivePath = target + logging.DebugStep(logger, "extract bundle", "found archive=%s", filepath.Base(target)) } } if staged.ArchivePath == "" || staged.MetadataPath == "" || staged.ChecksumPath == "" { return stagedFiles{}, fmt.Errorf("bundle missing required files") } + logging.DebugStep(logger, "extract bundle", "entries_extracted=%d", extracted) return staged, nil } -func copyRawArtifactsToWorkdir(cand *decryptCandidate, workDir string) (staged stagedFiles, err error) { - done := logging.DebugStart(logging.GetDefaultLogger(), "stage raw artifacts", "archive=%s workdir=%s", cand.RawArchivePath, workDir) +func copyRawArtifactsToWorkdir(ctx context.Context, cand *decryptCandidate, workDir string) (staged stagedFiles, err error) { + return copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, nil) +} + +func baseNameFromRemoteRef(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + parts := strings.SplitN(ref, ":", 2) + if len(parts) != 2 { + return filepath.Base(ref) + } + rel := strings.Trim(parts[1], "/") + if rel == "" { + return "" + } + return path.Base(rel) +} + +func rcloneCopyTo(ctx context.Context, remotePath, localPath string, showProgress bool) error { + args := []string{"copyto", remotePath, localPath} + if showProgress { + args = append(args, "--progress") + } + cmd := exec.CommandContext(ctx, "rclone", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func copyRawArtifactsToWorkdirWithLogger(ctx context.Context, cand *decryptCandidate, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { + done := logging.DebugStart(logger, "stage raw artifacts", "archive=%s workdir=%s rclone=%v", cand.RawArchivePath, workDir, cand.IsRclone) defer func() { done(err) }() - archiveDest := filepath.Join(workDir, filepath.Base(cand.RawArchivePath)) - if err := copyFile(restoreFS, cand.RawArchivePath, archiveDest); err != nil { - return stagedFiles{}, fmt.Errorf("copy archive: %w", err) + if ctx == nil { + ctx = context.Background() } - metadataDest := filepath.Join(workDir, filepath.Base(cand.RawMetadataPath)) - if err := copyFile(restoreFS, cand.RawMetadataPath, metadataDest); err != nil { - return stagedFiles{}, fmt.Errorf("copy metadata: %w", err) + if cand == nil { + return stagedFiles{}, fmt.Errorf("candidate is nil") } - checksumDest := filepath.Join(workDir, filepath.Base(cand.RawChecksumPath)) - if err := copyFile(restoreFS, cand.RawChecksumPath, checksumDest); err != nil { - return stagedFiles{}, fmt.Errorf("copy checksum: %w", err) + + archiveBase := filepath.Base(cand.RawArchivePath) + metaBase := filepath.Base(cand.RawMetadataPath) + sumBase := "" + if cand.IsRclone { + archiveBase = baseNameFromRemoteRef(cand.RawArchivePath) + metaBase = baseNameFromRemoteRef(cand.RawMetadataPath) + if cand.RawChecksumPath != "" { + sumBase = baseNameFromRemoteRef(cand.RawChecksumPath) + } + } else if cand.RawChecksumPath != "" { + sumBase = filepath.Base(cand.RawChecksumPath) } + if archiveBase == "" || metaBase == "" { + return stagedFiles{}, fmt.Errorf("invalid raw candidate paths") + } + + archiveDest := filepath.Join(workDir, archiveBase) + metadataDest := filepath.Join(workDir, metaBase) + checksumDest := "" + if sumBase != "" { + checksumDest = filepath.Join(workDir, sumBase) + } + + if cand.IsRclone { + logging.DebugStep(logger, "stage raw artifacts", "download archive to %s", archiveDest) + if err := rcloneCopyTo(ctx, cand.RawArchivePath, archiveDest, true); err != nil { + return stagedFiles{}, fmt.Errorf("rclone download archive: %w", err) + } + logging.DebugStep(logger, "stage raw artifacts", "download metadata to %s", metadataDest) + if err := rcloneCopyTo(ctx, cand.RawMetadataPath, metadataDest, false); err != nil { + return stagedFiles{}, fmt.Errorf("rclone download metadata: %w", err) + } + if cand.RawChecksumPath != "" && checksumDest != "" { + logging.DebugStep(logger, "stage raw artifacts", "download checksum to %s", checksumDest) + if err := rcloneCopyTo(ctx, cand.RawChecksumPath, checksumDest, false); err != nil { + logWarning(logger, "Failed to download checksum %s: %v", cand.RawChecksumPath, err) + checksumDest = "" + } + } + } else { + logging.DebugStep(logger, "stage raw artifacts", "copy archive to %s", archiveDest) + if err := copyFile(restoreFS, cand.RawArchivePath, archiveDest); err != nil { + return stagedFiles{}, fmt.Errorf("copy archive: %w", err) + } + logging.DebugStep(logger, "stage raw artifacts", "copy metadata to %s", metadataDest) + if err := copyFile(restoreFS, cand.RawMetadataPath, metadataDest); err != nil { + return stagedFiles{}, fmt.Errorf("copy metadata: %w", err) + } + if cand.RawChecksumPath != "" && checksumDest != "" { + logging.DebugStep(logger, "stage raw artifacts", "copy checksum to %s", checksumDest) + if err := copyFile(restoreFS, cand.RawChecksumPath, checksumDest); err != nil { + logWarning(logger, "Failed to copy checksum %s: %v", cand.RawChecksumPath, err) + checksumDest = "" + } + } + } + return stagedFiles{ ArchivePath: archiveDest, MetadataPath: metadataDest, @@ -735,7 +911,7 @@ func copyRawArtifactsToWorkdir(cand *decryptCandidate, workDir string) (staged s func decryptArchiveWithPrompts(ctx context.Context, reader *bufio.Reader, encryptedPath, outputPath string, logger *logging.Logger) error { for { fmt.Print("Enter decryption key or passphrase (0 = exit): ") - inputBytes, err := readPasswordWithContext(ctx) + inputBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() if err != nil { return err @@ -870,27 +1046,27 @@ func ensureWritablePath(ctx context.Context, reader *bufio.Reader, path, descrip fmt.Printf("%s %s already exists.\n", titleCaser.String(description), current) fmt.Println(" [1] Overwrite") - fmt.Println(" [2] Enter a different path") - fmt.Println(" [0] Exit") - fmt.Print("Choice: ") + fmt.Println(" [2] Enter a different path") + fmt.Println(" [0] Exit") + fmt.Print("Choice: ") - input, err := readLineWithContext(ctx, reader) - if err != nil { - return "", err - } - switch strings.TrimSpace(input) { - case "1": - if err := restoreFS.Remove(current); err != nil { - fmt.Printf("Failed to remove existing file: %v\n", err) - continue - } - return current, nil - case "2": - fmt.Print("Enter new path: ") - newPath, err := readLineWithContext(ctx, reader) + inputLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { return "", err } + switch strings.TrimSpace(inputLine) { + case "1": + if err := restoreFS.Remove(current); err != nil { + fmt.Printf("Failed to remove existing file: %v\n", err) + continue + } + return current, nil + case "2": + fmt.Print("Enter new path: ") + newPath, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return "", err + } trimmed := strings.TrimSpace(newPath) if trimmed == "" { continue diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index 0a60ff4..6618ef0 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "testing" + "time" "filippo.io/age" @@ -33,21 +34,19 @@ func TestBuildDecryptPathOptions(t *testing.T) { wantPaths []string wantLabel []string }{ - { - name: "all paths enabled", - cfg: &config.Config{ - BackupPath: "/backup/local", - SecondaryEnabled: true, - SecondaryPath: "/backup/secondary", - CloudEnabled: true, - CloudRemote: "/backup/cloud", + { + name: "all paths enabled", + cfg: &config.Config{ + BackupPath: "/backup/local", + SecondaryEnabled: true, + SecondaryPath: "/backup/secondary", + CloudEnabled: true, + CloudRemote: "/backup/cloud", + }, + wantCount: 3, + wantPaths: []string{"/backup/local", "/backup/secondary", "/backup/cloud"}, + wantLabel: []string{"Local backups", "Secondary backups", "Cloud backups"}, }, - // With pre-scan enabled, cloud is only shown if backups exist - // Since no actual backups exist, expect only local + secondary - wantCount: 2, - wantPaths: []string{"/backup/local", "/backup/secondary"}, - wantLabel: []string{"Local backups", "Secondary backups"}, - }, { name: "only local path", cfg: &config.Config{ @@ -92,32 +91,28 @@ func TestBuildDecryptPathOptions(t *testing.T) { wantPaths: []string{"/backup/local"}, wantLabel: []string{"Local backups"}, }, - { - name: "cloud with rclone remote included", - cfg: &config.Config{ - BackupPath: "/backup/local", - CloudEnabled: true, - CloudRemote: "gdrive:backups", // rclone remote + { + name: "cloud with rclone remote included", + cfg: &config.Config{ + BackupPath: "/backup/local", + CloudEnabled: true, + CloudRemote: "gdrive:backups", // rclone remote + }, + wantCount: 2, + wantPaths: []string{"/backup/local", "gdrive:backups"}, + wantLabel: []string{"Local backups", "Cloud backups (rclone)"}, }, - // With pre-scan enabled, cloud is only shown if backups exist - // Since no actual backups exist, expect only local - wantCount: 1, - wantPaths: []string{"/backup/local"}, - wantLabel: []string{"Local backups"}, - }, - { - name: "cloud with local absolute path included", - cfg: &config.Config{ - BackupPath: "/backup/local", - CloudEnabled: true, - CloudRemote: "/mnt/cloud/backups", + { + name: "cloud with local absolute path included", + cfg: &config.Config{ + BackupPath: "/backup/local", + CloudEnabled: true, + CloudRemote: "/mnt/cloud/backups", + }, + wantCount: 2, + wantPaths: []string{"/backup/local", "/mnt/cloud/backups"}, + wantLabel: []string{"Local backups", "Cloud backups"}, }, - // With pre-scan enabled, cloud is only shown if backups exist - // Since no actual backups exist, expect only local - wantCount: 1, - wantPaths: []string{"/backup/local"}, - wantLabel: []string{"Local backups"}, - }, { name: "secondary enabled but path empty", cfg: &config.Config{ @@ -140,19 +135,17 @@ func TestBuildDecryptPathOptions(t *testing.T) { wantPaths: []string{"/backup/local"}, wantLabel: []string{"Local backups"}, }, - { - name: "cloud absolute with colon allowed", - cfg: &config.Config{ - BackupPath: "/backup/local", - CloudEnabled: true, - CloudRemote: "/mnt/backups:foo", + { + name: "cloud absolute with colon allowed", + cfg: &config.Config{ + BackupPath: "/backup/local", + CloudEnabled: true, + CloudRemote: "/mnt/backups:foo", + }, + wantCount: 2, + wantPaths: []string{"/backup/local", "/mnt/backups:foo"}, + wantLabel: []string{"Local backups", "Cloud backups"}, }, - // With pre-scan enabled, cloud is only shown if backups exist - // Since no actual backups exist, expect only local - wantCount: 1, - wantPaths: []string{"/backup/local"}, - wantLabel: []string{"Local backups"}, - }, { name: "all paths empty", cfg: &config.Config{}, @@ -163,7 +156,7 @@ func TestBuildDecryptPathOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - options := buildDecryptPathOptions(tt.cfg) + options := buildDecryptPathOptions(tt.cfg, nil) if len(options) != tt.wantCount { t.Errorf("buildDecryptPathOptions() returned %d options; want %d", @@ -185,6 +178,180 @@ func TestBuildDecryptPathOptions(t *testing.T) { } } +func TestBaseNameFromRemoteRef(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want string + }{ + {"", ""}, + {"local/file.tar.xz", "file.tar.xz"}, + {"gdrive:", ""}, + {"gdrive:backup.tar.xz", "backup.tar.xz"}, + {"gdrive:dir/sub/backup.tar.xz", "backup.tar.xz"}, + {"gdrive:/dir/sub/backup.tar.xz", "backup.tar.xz"}, + {"gdrive:dir/sub/", "sub"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.in, func(t *testing.T) { + got := baseNameFromRemoteRef(tt.in) + if got != tt.want { + t.Fatalf("baseNameFromRemoteRef(%q)=%q; want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestInspectRcloneMetadataManifest_JSONArchivePathEmptyUsesRemoteArchivePath(t *testing.T) { + tmpDir := t.TempDir() + metadataPath := filepath.Join(tmpDir, "backup.tar.xz.metadata") + + createdAt := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + manifest := backup.Manifest{ + ArchivePath: "", + CreatedAt: createdAt, + ProxmoxType: "pve", + EncryptionMode: "none", + } + data, err := json.Marshal(&manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(metadataPath, data, 0o644); err != nil { + t.Fatalf("write metadata: %v", err) + } + + scriptPath := filepath.Join(tmpDir, "rclone") + script := "#!/bin/sh\ncat \"$METADATA_PATH\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + + if err := os.Setenv("METADATA_PATH", metadataPath); err != nil { + t.Fatalf("set METADATA_PATH: %v", err) + } + defer os.Unsetenv("METADATA_PATH") + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + got, err := inspectRcloneMetadataManifest(context.Background(), "gdrive:backup.tar.xz.metadata", "gdrive:backup.tar.xz", logger) + if err != nil { + t.Fatalf("inspectRcloneMetadataManifest error: %v", err) + } + if got.ArchivePath != "gdrive:backup.tar.xz" { + t.Fatalf("ArchivePath=%q; want %q", got.ArchivePath, "gdrive:backup.tar.xz") + } + if !got.CreatedAt.Equal(createdAt) { + t.Fatalf("CreatedAt=%s; want %s", got.CreatedAt, createdAt) + } +} + +func TestInspectRcloneMetadataManifest_LegacyInfersAgeFromArchiveExt(t *testing.T) { + tmpDir := t.TempDir() + metadataPath := filepath.Join(tmpDir, "backup.tar.xz.age.metadata") + + legacy := strings.Join([]string{ + "COMPRESSION_TYPE=xz", + "COMPRESSION_LEVEL=6", + "PROXMOX_TYPE=pve", + "HOSTNAME=node1", + "SCRIPT_VERSION=v1.2.3", + "", + }, "\n") + if err := os.WriteFile(metadataPath, []byte(legacy), 0o644); err != nil { + t.Fatalf("write metadata: %v", err) + } + + scriptPath := filepath.Join(tmpDir, "rclone") + script := "#!/bin/sh\ncat \"$METADATA_PATH\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + + if err := os.Setenv("METADATA_PATH", metadataPath); err != nil { + t.Fatalf("set METADATA_PATH: %v", err) + } + defer os.Unsetenv("METADATA_PATH") + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + got, err := inspectRcloneMetadataManifest(context.Background(), "gdrive:backup.tar.xz.age.metadata", "gdrive:backup.tar.xz.age", logger) + if err != nil { + t.Fatalf("inspectRcloneMetadataManifest error: %v", err) + } + if got.EncryptionMode != "age" { + t.Fatalf("EncryptionMode=%q; want %q", got.EncryptionMode, "age") + } + if got.CompressionType != "xz" || got.CompressionLevel != 6 { + t.Fatalf("compression=%q/%d; want xz/6", got.CompressionType, got.CompressionLevel) + } + if got.Hostname != "node1" || got.ProxmoxType != "pve" { + t.Fatalf("Hostname=%q ProxmoxType=%q; want node1/pve", got.Hostname, got.ProxmoxType) + } + if got.ScriptVersion != "v1.2.3" { + t.Fatalf("ScriptVersion=%q; want %q", got.ScriptVersion, "v1.2.3") + } +} + +func TestInspectRcloneBundleManifest_ReturnsErrorWhenManifestMissing(t *testing.T) { + tmpDir := t.TempDir() + bundlePath := filepath.Join(tmpDir, "backup.bundle.tar") + + f, err := os.Create(bundlePath) + if err != nil { + t.Fatalf("create bundle: %v", err) + } + tw := tar.NewWriter(f) + if err := tw.WriteHeader(&tar.Header{Name: "payload.txt", Mode: 0o600, Size: int64(len("x"))}); err != nil { + t.Fatalf("write header: %v", err) + } + if _, err := tw.Write([]byte("x")); err != nil { + t.Fatalf("write body: %v", err) + } + _ = tw.Close() + _ = f.Close() + + scriptPath := filepath.Join(tmpDir, "rclone") + script := "#!/bin/sh\ncat \"$BUNDLE_PATH\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + + if err := os.Setenv("BUNDLE_PATH", bundlePath); err != nil { + t.Fatalf("set BUNDLE_PATH: %v", err) + } + defer os.Unsetenv("BUNDLE_PATH") + + logger := logging.New(types.LogLevelError, false) + logger.SetOutput(io.Discard) + _, err = inspectRcloneBundleManifest(context.Background(), "gdrive:backup.bundle.tar", logger) + if err == nil { + t.Fatalf("expected error for missing manifest entry") + } + if !strings.Contains(strings.ToLower(err.Error()), "manifest not found") { + t.Fatalf("error=%v; want manifest-not-found", err) + } +} + func TestSelectDecryptCandidateEncryptedFlag(t *testing.T) { createBundleWithMode := func(dir, name, mode string) string { path := filepath.Join(dir, name) @@ -1787,7 +1954,7 @@ func TestCopyRawArtifactsToWorkdir_Success(t *testing.T) { RawChecksumPath: checksumPath, } - staged, err := copyRawArtifactsToWorkdir(cand, workDir) + staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) if err != nil { t.Fatalf("copyRawArtifactsToWorkdir error: %v", err) } @@ -1807,7 +1974,7 @@ func TestCopyRawArtifactsToWorkdir_ArchiveError(t *testing.T) { RawChecksumPath: "/nonexistent/backup.sha256", } - _, err := copyRawArtifactsToWorkdir(cand, t.TempDir()) + _, err := copyRawArtifactsToWorkdir(context.Background(), cand, t.TempDir()) if err == nil { t.Fatal("expected error for nonexistent archive") } @@ -1836,7 +2003,7 @@ func TestCopyRawArtifactsToWorkdir_MetadataError(t *testing.T) { RawChecksumPath: "/nonexistent/backup.sha256", } - _, err := copyRawArtifactsToWorkdir(cand, workDir) + _, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) if err == nil { t.Fatal("expected error for nonexistent metadata") } @@ -1869,12 +2036,102 @@ func TestCopyRawArtifactsToWorkdir_ChecksumError(t *testing.T) { RawChecksumPath: "/nonexistent/backup.sha256", } - _, err := copyRawArtifactsToWorkdir(cand, workDir) - if err == nil { - t.Fatal("expected error for nonexistent checksum") + staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + if err != nil { + t.Fatalf("expected checksum to be optional, got error: %v", err) + } + if staged.ChecksumPath != "" { + t.Fatalf("ChecksumPath = %q; want empty when checksum missing", staged.ChecksumPath) + } +} + +func TestCopyRawArtifactsToWorkdir_RcloneDownloadsRawArtifacts(t *testing.T) { + origFS := restoreFS + restoreFS = osFS{} + t.Cleanup(func() { restoreFS = origFS }) + + srcDir := t.TempDir() + binDir := t.TempDir() + workDir := t.TempDir() + + archiveSrc := filepath.Join(srcDir, "backup.tar.xz") + if err := os.WriteFile(archiveSrc, []byte("archive data"), 0o644); err != nil { + t.Fatalf("write archive: %v", err) + } + metadataSrc := filepath.Join(srcDir, "backup.tar.xz.metadata") + if err := os.WriteFile(metadataSrc, []byte("{}"), 0o644); err != nil { + t.Fatalf("write metadata: %v", err) + } + checksumSrc := filepath.Join(srcDir, "backup.tar.xz.sha256") + if err := os.WriteFile(checksumSrc, []byte("checksum"), 0o644); err != nil { + t.Fatalf("write checksum: %v", err) + } + + scriptPath := filepath.Join(binDir, "rclone") + script := `#!/bin/sh +subcmd="$1" +case "$subcmd" in + copyto) + src="$2" + dst="$3" + case "$src" in + gdrive:backup.tar.xz) cp "$ARCHIVE_SRC" "$dst" ;; + gdrive:backup.tar.xz.metadata) cp "$METADATA_SRC" "$dst" ;; + gdrive:backup.tar.xz.sha256) cp "$CHECKSUM_SRC" "$dst" ;; + *) echo "unexpected copy source: $src" >&2; exit 1 ;; + esac + ;; + *) + echo "unexpected subcommand: $subcmd" >&2 + exit 1 + ;; +esac +` + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake rclone: %v", err) + } + + oldPath := os.Getenv("PATH") + if err := os.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + defer os.Setenv("PATH", oldPath) + + if err := os.Setenv("ARCHIVE_SRC", archiveSrc); err != nil { + t.Fatalf("set ARCHIVE_SRC: %v", err) + } + if err := os.Setenv("METADATA_SRC", metadataSrc); err != nil { + t.Fatalf("set METADATA_SRC: %v", err) + } + if err := os.Setenv("CHECKSUM_SRC", checksumSrc); err != nil { + t.Fatalf("set CHECKSUM_SRC: %v", err) + } + defer os.Unsetenv("ARCHIVE_SRC") + defer os.Unsetenv("METADATA_SRC") + defer os.Unsetenv("CHECKSUM_SRC") + + cand := &decryptCandidate{ + IsRclone: true, + RawArchivePath: "gdrive:backup.tar.xz", + RawMetadataPath: "gdrive:backup.tar.xz.metadata", + RawChecksumPath: "gdrive:backup.tar.xz.sha256", + } + + staged, err := copyRawArtifactsToWorkdir(context.Background(), cand, workDir) + if err != nil { + t.Fatalf("copyRawArtifactsToWorkdir error: %v", err) + } + if _, err := os.Stat(staged.ArchivePath); err != nil { + t.Fatalf("staged archive missing: %v", err) + } + if _, err := os.Stat(staged.MetadataPath); err != nil { + t.Fatalf("staged metadata missing: %v", err) + } + if staged.ChecksumPath == "" { + t.Fatalf("expected checksum path to be set") } - if !strings.Contains(err.Error(), "copy checksum") { - t.Fatalf("expected 'copy checksum' error, got: %v", err) + if _, err := os.Stat(staged.ChecksumPath); err != nil { + t.Fatalf("staged checksum missing: %v", err) } } diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index e25312b..ef064f2 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -53,7 +53,7 @@ func RunDecryptWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg done := logging.DebugStart(logger, "decrypt workflow (tui)", "version=%s", version) defer func() { done(err) }() - selection, err := runDecryptSelectionWizard(ctx, cfg, configPath, buildSig) + selection, err := runDecryptSelectionWizard(ctx, cfg, logger, configPath, buildSig) if err != nil { if errors.Is(err, ErrDecryptAborted) { return ErrDecryptAborted @@ -133,19 +133,25 @@ func RunDecryptWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg return nil } -func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPath, buildSig string) (*decryptSelection, error) { +func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, logger *logging.Logger, configPath, buildSig string) (selection *decryptSelection, err error) { if ctx == nil { ctx = context.Background() } - options := buildDecryptPathOptions(cfg) + done := logging.DebugStart(logger, "decrypt selection wizard", "tui=true") + defer func() { done(err) }() + options := buildDecryptPathOptions(cfg, logger) if len(options) == 0 { - return nil, fmt.Errorf("no backup paths configured in backup.env") + err = fmt.Errorf("no backup paths configured in backup.env") + return nil, err + } + for _, opt := range options { + logging.DebugStep(logger, "decrypt selection wizard", "option label=%q path=%q rclone=%v", opt.Label, opt.Path, opt.IsRclone) } - app := tui.NewApp() + app := newTUIApp() pages := tview.NewPages() - selection := &decryptSelection{} + selection = &decryptSelection{} var selectionErr error var scan scanController @@ -165,25 +171,30 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPa return } selectedOption := options[index] + logging.DebugStep(logger, "decrypt selection wizard", "selected source label=%q path=%q rclone=%v", selectedOption.Label, selectedOption.Path, selectedOption.IsRclone) pages.SwitchToPage("loading") go func() { scanCtx, finish := scan.Start(ctx) defer finish() var candidates []*decryptCandidate - var err error + var scanErr error + scanDone := logging.DebugStart(logger, "scan backup source", "path=%s rclone=%v", selectedOption.Path, selectedOption.IsRclone) + defer func() { scanDone(scanErr) }() if selectedOption.IsRclone { - candidates, err = discoverRcloneBackups(scanCtx, selectedOption.Path, logging.GetDefaultLogger()) + candidates, scanErr = discoverRcloneBackups(scanCtx, selectedOption.Path, logger) } else { - candidates, err = discoverBackupCandidates(logging.GetDefaultLogger(), selectedOption.Path) + candidates, scanErr = discoverBackupCandidates(logger, selectedOption.Path) } + logging.DebugStep(logger, "scan backup source", "candidates=%d", len(candidates)) if scanCtx.Err() != nil { + scanErr = scanCtx.Err() return } app.QueueUpdateDraw(func() { - if err != nil { - message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, err) + if scanErr != nil { + message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, scanErr) showErrorModal(app, pages, configPath, buildSig, message, func() { pages.SwitchToPage("paths") }) @@ -213,6 +224,7 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPa }() }) pathList.SetDoneFunc(func() { + logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (done func)") scan.Cancel() selectionErr = ErrDecryptAborted app.Stop() @@ -233,6 +245,7 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPa form.Form.SetFocus(0) form.SetOnCancel(func() { + logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (form)") scan.Cancel() selectionErr = ErrDecryptAborted }) @@ -248,6 +261,7 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPa loadingForm := components.NewForm(app) loadingForm.SetOnCancel(func() { + logging.DebugStep(logger, "decrypt selection wizard", "cancel requested (loading form)") scan.Cancel() selectionErr = ErrDecryptAborted }) @@ -260,14 +274,17 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, configPa pages.AddPage("loading", loadingPage, true, false) app.SetRoot(pages, true).SetFocus(form.Form) - if err := app.Run(); err != nil { + if runErr := app.Run(); runErr != nil { + err = runErr return nil, err } if selectionErr != nil { - return nil, selectionErr + err = selectionErr + return nil, err } if selection.Candidate == nil || selection.DestDir == "" { - return nil, ErrDecryptAborted + err = ErrDecryptAborted + return nil, err } return selection, nil } @@ -570,7 +587,7 @@ func ensureWritablePathTUI(path, description, configPath, buildSig string) (stri } func promptOverwriteAction(path, description, failureMessage, configPath, buildSig string) (string, error) { - app := tui.NewApp() + app := newTUIApp() var choice string message := fmt.Sprintf("The %s [yellow]%s[white] already exists.\nSelect how you want to proceed.", description, path) @@ -609,7 +626,7 @@ func promptOverwriteAction(path, description, failureMessage, configPath, buildS } func promptNewPathInput(defaultPath, configPath, buildSig string) (string, error) { - app := tui.NewApp() + app := newTUIApp() var newPath string var cancelled bool @@ -696,10 +713,10 @@ func preparePlainBundleTUI(ctx context.Context, cand *decryptCandidate, version switch cand.Source { case sourceBundle: logger.Debug("Extracting bundle %s", filepath.Base(cand.BundlePath)) - staged, err = extractBundleToWorkdir(cand.BundlePath, workDir) + staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger) case sourceRaw: logger.Debug("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath)) - staged, err = copyRawArtifactsToWorkdir(cand, workDir) + staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } @@ -782,7 +799,7 @@ func decryptArchiveWithTUIPrompts(ctx context.Context, encryptedPath, outputPath } func promptDecryptIdentity(displayName, configPath, buildSig, errorMessage string) ([]age.Identity, error) { - app := tui.NewApp() + app := newTUIApp() var ( chosenIdentity []age.Identity cancelled bool diff --git a/internal/orchestrator/decrypt_tui_simulation_test.go b/internal/orchestrator/decrypt_tui_simulation_test.go new file mode 100644 index 0000000..d36f36f --- /dev/null +++ b/internal/orchestrator/decrypt_tui_simulation_test.go @@ -0,0 +1,37 @@ +package orchestrator + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func TestPromptDecryptIdentity_CancelReturnsAborted(t *testing.T) { + // Focus starts on the password field; tab to Cancel and submit. + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyTab, tcell.KeyEnter}) + + _, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + if err != ErrDecryptAborted { + t.Fatalf("err=%v; want %v", err, ErrDecryptAborted) + } +} + +func TestPromptDecryptIdentity_PassphraseReturnsIdentity(t *testing.T) { + passphrase := "test passphrase" + + var seq []simKey + for _, r := range passphrase { + seq = append(seq, simKey{Key: tcell.KeyRune, R: r}) + } + seq = append(seq, simKey{Key: tcell.KeyTab}, simKey{Key: tcell.KeyEnter}) + withSimAppSequence(t, seq) + + ids, err := promptDecryptIdentity("backup", "/tmp/config.env", "sig", "") + if err != nil { + t.Fatalf("promptDecryptIdentity error: %v", err) + } + if len(ids) == 0 { + t.Fatalf("expected at least one identity") + } +} + diff --git a/internal/orchestrator/decrypt_workflow_test.go b/internal/orchestrator/decrypt_workflow_test.go index b7a0261..3f68fa4 100644 --- a/internal/orchestrator/decrypt_workflow_test.go +++ b/internal/orchestrator/decrypt_workflow_test.go @@ -67,7 +67,7 @@ func TestRunDecryptWorkflow_BundleNotFound(t *testing.T) { } } -func TestPreparePlainBundle_InvalidChecksum(t *testing.T) { +func TestPreparePlainBundle_AllowsMissingRawChecksumSidecar(t *testing.T) { dir := t.TempDir() archive := filepath.Join(dir, "bad.bundle.tar") if err := os.WriteFile(archive, []byte("data"), 0o640); err != nil { @@ -83,7 +83,8 @@ func TestPreparePlainBundle_InvalidChecksum(t *testing.T) { if err := os.WriteFile(metaPath, data, 0o640); err != nil { t.Fatalf("write metadata: %v", err) } - // No checksum file to trigger error + // No checksum file: ProxSave should still allow restore/decrypt to proceed + // (it re-computes checksums on the staged/plain archive anyway). cand := &decryptCandidate{ Manifest: manifest, @@ -98,8 +99,8 @@ func TestPreparePlainBundle_InvalidChecksum(t *testing.T) { restoreFS = osFS{} t.Cleanup(func() { restoreFS = osFS{} }) - if _, err := preparePlainBundle(context.Background(), reader, cand, "", logging.New(types.LogLevelInfo, false)); err == nil { - t.Fatalf("expected error due to missing checksum file") + if _, err := preparePlainBundle(context.Background(), reader, cand, "", logging.New(types.LogLevelInfo, false)); err != nil { + t.Fatalf("expected missing checksum to be tolerated, got error: %v", err) } } diff --git a/internal/orchestrator/deps.go b/internal/orchestrator/deps.go index ed48927..b025c0b 100644 --- a/internal/orchestrator/deps.go +++ b/internal/orchestrator/deps.go @@ -34,9 +34,9 @@ type FS interface { // Prompter encapsulates interactive prompts. type Prompter interface { - SelectRestoreMode(logger *logging.Logger, systemType SystemType) (RestoreMode, error) - SelectCategories(logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) - ConfirmRestore(logger *logging.Logger) (bool, error) + SelectRestoreMode(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) + SelectCategories(ctx context.Context, logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) + ConfirmRestore(ctx context.Context, logger *logging.Logger) (bool, error) } // SystemDetector abstracts system-type detection. @@ -93,16 +93,16 @@ func (osFS) Rename(oldpath, newpath string) error { return os.Rename(ol type consolePrompter struct{} -func (consolePrompter) SelectRestoreMode(logger *logging.Logger, systemType SystemType) (RestoreMode, error) { - return ShowRestoreModeMenu(logger, systemType) +func (consolePrompter) SelectRestoreMode(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) { + return ShowRestoreModeMenu(ctx, logger, systemType) } -func (consolePrompter) SelectCategories(logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) { - return ShowCategorySelectionMenu(logger, available, systemType) +func (consolePrompter) SelectCategories(ctx context.Context, logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) { + return ShowCategorySelectionMenu(ctx, logger, available, systemType) } -func (consolePrompter) ConfirmRestore(logger *logging.Logger) (bool, error) { - return ConfirmRestoreOperation(logger) +func (consolePrompter) ConfirmRestore(ctx context.Context, logger *logging.Logger) (bool, error) { + return ConfirmRestoreOperation(ctx, logger) } type realSystemDetector struct{} diff --git a/internal/orchestrator/deps_additional_test.go b/internal/orchestrator/deps_additional_test.go index 4a4f6a1..7ff5b57 100644 --- a/internal/orchestrator/deps_additional_test.go +++ b/internal/orchestrator/deps_additional_test.go @@ -118,7 +118,7 @@ func TestConsolePrompterWrappers(t *testing.T) { os.Stdin = r defer r.Close() - mode, err := (consolePrompter{}).SelectRestoreMode(logger, SystemTypePVE) + mode, err := (consolePrompter{}).SelectRestoreMode(context.Background(), logger, SystemTypePVE) if err != nil { t.Fatalf("SelectRestoreMode error: %v", err) } @@ -143,7 +143,7 @@ func TestConsolePrompterWrappers(t *testing.T) { os.Stdin = r defer r.Close() - cats, err := (consolePrompter{}).SelectCategories(logger, available, SystemTypePVE) + cats, err := (consolePrompter{}).SelectCategories(context.Background(), logger, available, SystemTypePVE) if err != nil { t.Fatalf("SelectCategories error: %v", err) } @@ -162,7 +162,7 @@ func TestConsolePrompterWrappers(t *testing.T) { os.Stdin = r defer r.Close() - ok, err := (consolePrompter{}).ConfirmRestore(logger) + ok, err := (consolePrompter{}).ConfirmRestore(context.Background(), logger) if err != nil { t.Fatalf("ConfirmRestore error: %v", err) } diff --git a/internal/orchestrator/deps_test.go b/internal/orchestrator/deps_test.go index 6724a0c..aa2a58d 100644 --- a/internal/orchestrator/deps_test.go +++ b/internal/orchestrator/deps_test.go @@ -195,18 +195,18 @@ type FakePrompter struct { Err error } -func (f *FakePrompter) SelectRestoreMode(logger *logging.Logger, systemType SystemType) (RestoreMode, error) { +func (f *FakePrompter) SelectRestoreMode(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) { return f.Mode, f.Err } -func (f *FakePrompter) SelectCategories(logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) { +func (f *FakePrompter) SelectCategories(ctx context.Context, logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) { if f.Err != nil { return nil, f.Err } return f.Categories, nil } -func (f *FakePrompter) ConfirmRestore(logger *logging.Logger) (bool, error) { +func (f *FakePrompter) ConfirmRestore(ctx context.Context, logger *logging.Logger) (bool, error) { return f.Confirm, f.Err } diff --git a/internal/orchestrator/encryption.go b/internal/orchestrator/encryption.go index aa6f400..5c2be38 100644 --- a/internal/orchestrator/encryption.go +++ b/internal/orchestrator/encryption.go @@ -6,17 +6,15 @@ import ( "context" "errors" "fmt" - "io" "os" - "os/signal" "path/filepath" "strings" - "syscall" "time" "unicode" "filippo.io/age" "filippo.io/age/agessh" + "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/pkg/bech32" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/scrypt" @@ -145,27 +143,11 @@ func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath stri wizardCtx, wizardCancel := context.WithCancel(ctx) defer wizardCancel() - // Register local SIGINT handler for wizard - treat Ctrl+C as "Exit setup" - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT) - defer signal.Stop(sigChan) // Cleanup: restore normal signal handling after wizard - - // Handle SIGINT as "exit wizard" instead of "graceful shutdown" - go func() { - select { - case <-sigChan: - fmt.Println("\n^C detected - exiting setup...") - wizardCancel() - case <-wizardCtx.Done(): - // Wizard completed normally or parent context cancelled - } - }() - recipientPath := targetPath if o.forceNewAgeRecipient && recipientPath != "" { if _, err := os.Stat(recipientPath); err == nil { fmt.Printf("WARNING: this will remove the existing AGE recipients stored at %s. Existing backups remain decryptable with your old private key.\n", recipientPath) - confirm, errPrompt := promptYesNo(wizardCtx, reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) + confirm, errPrompt := promptYesNoAge(wizardCtx, reader, fmt.Sprintf("Delete %s and enter a new recipient? [y/N]: ", recipientPath)) if errPrompt != nil { return nil, "", errPrompt } @@ -186,7 +168,7 @@ func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath stri fmt.Println("[2] Generate an AGE public key using a personal passphrase/password — not stored on the server") fmt.Println("[3] Generate an AGE public key from an existing personal private key — not stored on the server") fmt.Println("[4] Exit setup") - option, err := promptOption(wizardCtx, reader, "Select an option [1-4]: ") + option, err := promptOptionAge(wizardCtx, reader, "Select an option [1-4]: ") if err != nil { return nil, "", err } @@ -197,14 +179,14 @@ func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath stri var value string switch option { case "1": - value, err = promptPublicRecipient(wizardCtx, reader) + value, err = promptPublicRecipientAge(wizardCtx, reader) case "2": - value, err = promptPassphraseRecipient(wizardCtx) + value, err = promptPassphraseRecipientAge(wizardCtx) if err == nil { o.logger.Info("Derived deterministic AGE public key from passphrase (no secrets stored)") } case "3": - value, err = promptPrivateKeyRecipient(wizardCtx) + value, err = promptPrivateKeyRecipientAge(wizardCtx) } if err != nil { o.logger.Warning("Encryption setup: %v", err) @@ -214,7 +196,7 @@ func (o *Orchestrator) runAgeSetupWizard(ctx context.Context, candidatePath stri recipients = append(recipients, value) } - more, err := promptYesNo(wizardCtx, reader, "Add another recipient? [y/N]: ") + more, err := promptYesNoAge(wizardCtx, reader, "Add another recipient? [y/N]: ") if err != nil { return nil, "", err } @@ -247,14 +229,14 @@ func (o *Orchestrator) isInteractiveShell() bool { return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) } -func promptOption(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) { +func promptOptionAge(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) { for { fmt.Print(prompt) - input, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - return "", err + return "", mapInputAbortToAgeAbort(err) } - sw := strings.TrimSpace(input) + sw := strings.TrimSpace(line) switch sw { case "1", "2", "3", "4": return sw, nil @@ -265,11 +247,11 @@ func promptOption(ctx context.Context, reader *bufio.Reader, prompt string) (str } } -func promptPublicRecipient(ctx context.Context, reader *bufio.Reader) (string, error) { +func promptPublicRecipientAge(ctx context.Context, reader *bufio.Reader) (string, error) { fmt.Print("Paste your AGE public recipient (starts with \"age1...\"). Press Enter when done: ") - line, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - return "", err + return "", mapInputAbortToAgeAbort(err) } value := strings.TrimSpace(line) if value == "" { @@ -278,12 +260,12 @@ func promptPublicRecipient(ctx context.Context, reader *bufio.Reader) (string, e return value, nil } -func promptPrivateKeyRecipient(ctx context.Context) (string, error) { +func promptPrivateKeyRecipientAge(ctx context.Context) (string, error) { fmt.Print("Paste your AGE private key (not stored; input is not echoed). Press Enter when done: ") - secretBytes, err := readPasswordWithContext(ctx) + secretBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() if err != nil { - return "", err + return "", mapInputAbortToAgeAbort(err) } defer zeroBytes(secretBytes) @@ -300,8 +282,8 @@ func promptPrivateKeyRecipient(ctx context.Context) (string, error) { } // promptPassphraseRecipient derives a deterministic AGE public key from a passphrase -func promptPassphraseRecipient(ctx context.Context) (string, error) { - pass, err := promptAndConfirmPassphrase(ctx) +func promptPassphraseRecipientAge(ctx context.Context) (string, error) { + pass, err := promptAndConfirmPassphraseAge(ctx) if err != nil { return "", err } @@ -315,12 +297,12 @@ func promptPassphraseRecipient(ctx context.Context) (string, error) { } // promptAndConfirmPassphrase asks the user to enter a passphrase twice and checks strength. -func promptAndConfirmPassphrase(ctx context.Context) (string, error) { +func promptAndConfirmPassphraseAge(ctx context.Context) (string, error) { fmt.Print("Enter the passphrase to derive your AGE public key (input is not echoed). Press Enter when done: ") - passBytes, err := readPasswordWithContext(ctx) + passBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() if err != nil { - return "", err + return "", mapInputAbortToAgeAbort(err) } defer zeroBytes(passBytes) @@ -335,11 +317,11 @@ func promptAndConfirmPassphrase(ctx context.Context) (string, error) { zeroBytes(trimmed) fmt.Print("Re-enter the passphrase to confirm: ") - confirmBytes, err := readPasswordWithContext(ctx) + confirmBytes, err := input.ReadPasswordWithContext(ctx, readPassword, int(os.Stdin.Fd())) fmt.Println() if err != nil { resetString(&pass) - return "", err + return "", mapInputAbortToAgeAbort(err) } defer zeroBytes(confirmBytes) @@ -357,13 +339,13 @@ func promptAndConfirmPassphrase(ctx context.Context) (string, error) { return pass, nil } -func promptYesNo(ctx context.Context, reader *bufio.Reader, prompt string) (bool, error) { +func promptYesNoAge(ctx context.Context, reader *bufio.Reader, prompt string) (bool, error) { fmt.Print(prompt) - input, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - return false, err + return false, mapInputAbortToAgeAbort(err) } - switch strings.ToLower(strings.TrimSpace(input)) { + switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": return true, nil default: @@ -462,60 +444,16 @@ func cloneRecipients(src []age.Recipient) []age.Recipient { return dst } -func mapInputError(err error) error { +func mapInputAbortToAgeAbort(err error) error { if err == nil { return nil } - if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) { - return ErrAgeRecipientSetupAborted - } - errStr := strings.ToLower(err.Error()) - if strings.Contains(errStr, "use of closed file") || - strings.Contains(errStr, "bad file descriptor") || - strings.Contains(errStr, "file already closed") { + if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) { return ErrAgeRecipientSetupAborted } return err } -// readLineWithContext reads a single line from the reader and supports cancellation. -func readLineWithContext(ctx context.Context, r *bufio.Reader) (string, error) { - type res struct { - s string - err error - } - ch := make(chan res, 1) - go func() { - s, e := r.ReadString('\n') - ch <- res{s: s, err: mapInputError(e)} - }() - select { - case <-ctx.Done(): - return "", ErrAgeRecipientSetupAborted - case out := <-ch: - return out.s, out.err - } -} - -// readPasswordWithContext reads a password (no echo) and supports cancellation. -func readPasswordWithContext(ctx context.Context) ([]byte, error) { - type res struct { - b []byte - err error - } - ch := make(chan res, 1) - go func() { - b, e := readPassword(int(os.Stdin.Fd())) - ch <- res{b: b, err: mapInputError(e)} - }() - select { - case <-ctx.Done(): - return nil, ErrAgeRecipientSetupAborted - case out := <-ch: - return out.b, out.err - } -} - func backupExistingRecipientFile(path string) error { if path == "" { return nil diff --git a/internal/orchestrator/encryption_exported_test.go b/internal/orchestrator/encryption_exported_test.go new file mode 100644 index 0000000..912f991 --- /dev/null +++ b/internal/orchestrator/encryption_exported_test.go @@ -0,0 +1,285 @@ +package orchestrator + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "filippo.io/age" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" +) + +func TestMapInputAbortToAgeAbort(t *testing.T) { + if mapInputAbortToAgeAbort(nil) != nil { + t.Fatalf("expected nil") + } + if !errors.Is(mapInputAbortToAgeAbort(input.ErrInputAborted), ErrAgeRecipientSetupAborted) { + t.Fatalf("expected ErrAgeRecipientSetupAborted for ErrInputAborted") + } + if !errors.Is(mapInputAbortToAgeAbort(context.Canceled), ErrAgeRecipientSetupAborted) { + t.Fatalf("expected ErrAgeRecipientSetupAborted for context.Canceled") + } + + sentinel := errors.New("sentinel") + if mapInputAbortToAgeAbort(sentinel) != sentinel { + t.Fatalf("expected passthrough for non-abort errors") + } +} + +func TestValidatePassphraseStrengthExported(t *testing.T) { + if err := ValidatePassphraseStrength("Str0ng!Passphrase"); err != nil { + t.Fatalf("expected strong passphrase to pass, got %v", err) + } + if err := ValidatePassphraseStrength("Short1!"); err == nil { + t.Fatalf("expected short passphrase to fail") + } +} + +func TestValidateRecipientStringExported(t *testing.T) { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("GenerateX25519Identity: %v", err) + } + + if err := ValidateRecipientString(" "); err == nil { + t.Fatalf("expected empty recipient to fail") + } + if err := ValidateRecipientString("not-a-recipient"); err == nil { + t.Fatalf("expected invalid recipient to fail") + } + if err := ValidateRecipientString(" " + id.Recipient().String() + " "); err != nil { + t.Fatalf("expected valid recipient to pass, got %v", err) + } +} + +func TestDedupeRecipientStringsExported(t *testing.T) { + got := DedupeRecipientStrings([]string{" age1alpha ", "", "age1alpha", "ssh-ed25519 AAA", "ssh-ed25519 AAA"}) + want := []string{"age1alpha", "ssh-ed25519 AAA"} + if len(got) != len(want) { + t.Fatalf("got=%v; want=%v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d]=%q; want %q", i, got[i], want[i]) + } + } +} + +func TestWriteRecipientFileExported(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "identity", "age", "recipient.txt") + + if err := WriteRecipientFile(path, []string{" age1alpha ", "", "age1alpha", "ssh-ed25519 AAA"}); err != nil { + t.Fatalf("WriteRecipientFile error: %v", err) + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if got := string(content); got != "age1alpha\nssh-ed25519 AAA\n" { + t.Fatalf("content=%q; want %q", got, "age1alpha\nssh-ed25519 AAA\n") + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("perm=%#o; want %#o", perm, 0o600) + } +} + +func TestWriteRecipientFileExported_NoRecipientsFails(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "recipient.txt") + + if err := WriteRecipientFile(path, []string{"", " "}); err == nil { + t.Fatalf("expected error") + } +} + +func TestBackupAgeRecipientFileExported(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "recipient.txt") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + + if err := BackupAgeRecipientFile(path); err != nil { + t.Fatalf("BackupAgeRecipientFile error: %v", err) + } + matches, err := filepath.Glob(path + ".bak-*") + if err != nil || len(matches) != 1 { + t.Fatalf("expected backup file, got %v err=%v", matches, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("original path should have been moved, stat err=%v", err) + } +} + +func TestBackupAgeRecipientFileExported_EmptyPathNoop(t *testing.T) { + if err := BackupAgeRecipientFile(""); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestDefaultAgeRecipientFile(t *testing.T) { + if got := (&Orchestrator{}).defaultAgeRecipientFile(); got != "" { + t.Fatalf("got=%q; want empty", got) + } + + o := &Orchestrator{cfg: &config.Config{BaseDir: "/tmp/base"}} + got := o.defaultAgeRecipientFile() + if !strings.HasSuffix(got, "/tmp/base/identity/age/recipient.txt") { + t.Fatalf("got=%q; want suffix %q", got, "/tmp/base/identity/age/recipient.txt") + } +} + +func TestPrepareAgeRecipients_NoEncryptionNoop(t *testing.T) { + o := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: false}) + got, err := o.prepareAgeRecipients(context.Background()) + if err != nil { + t.Fatalf("prepareAgeRecipients error: %v", err) + } + if got != nil { + t.Fatalf("got=%v; want nil", got) + } +} + +func TestPrepareAgeRecipients_NoRecipientsNonInteractiveErrors(t *testing.T) { + origIn := os.Stdin + origOut := os.Stdout + t.Cleanup(func() { + os.Stdin = origIn + os.Stdout = origOut + }) + + inR, inW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdin: %v", err) + } + outR, outW, err := os.Pipe() + if err != nil { + inR.Close() + inW.Close() + t.Fatalf("pipe stdout: %v", err) + } + defer inR.Close() + defer inW.Close() + defer outR.Close() + defer outW.Close() + + os.Stdin = inR + os.Stdout = outW + + o := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: true, BaseDir: t.TempDir()}) + _, err = o.prepareAgeRecipients(context.Background()) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestWriteRecipientFile_CreateDirError(t *testing.T) { + tmp := t.TempDir() + // Make the would-be directory a file so MkdirAll fails. + if err := os.WriteFile(filepath.Join(tmp, "identity"), []byte("x"), 0o600); err != nil { + t.Fatalf("write marker: %v", err) + } + err := writeRecipientFile(filepath.Join(tmp, "identity", "age", "recipient.txt"), []string{"age1alpha"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "create recipient directory") { + t.Fatalf("err=%v; want create recipient directory error", err) + } +} + +func TestWriteRecipientFile_WriteError(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "recipient.txt") + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + err := writeRecipientFile(path, []string{"age1alpha"}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "write recipient file") { + t.Fatalf("err=%v; want write recipient file error", err) + } +} + +func TestBackupExistingRecipientFile_Noops(t *testing.T) { + if err := backupExistingRecipientFile(""); err != nil { + t.Fatalf("expected nil, got %v", err) + } + if err := backupExistingRecipientFile(filepath.Join(t.TempDir(), "missing.txt")); err != nil { + t.Fatalf("expected nil for missing file, got %v", err) + } +} + +func TestRunAgeSetupWizard_ExitReturnsAborted(t *testing.T) { + tmp := t.TempDir() + inputFile := filepath.Join(tmp, "stdin.txt") + if err := os.WriteFile(inputFile, []byte("4\n"), 0o600); err != nil { + t.Fatalf("write stdin: %v", err) + } + f, err := os.Open(inputFile) + if err != nil { + t.Fatalf("open stdin: %v", err) + } + defer f.Close() + + origIn := os.Stdin + t.Cleanup(func() { os.Stdin = origIn }) + os.Stdin = f + + o := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: true, BaseDir: tmp}) + _, _, err = o.runAgeSetupWizard(context.Background(), filepath.Join(tmp, "recipient.txt")) + if !errors.Is(err, ErrAgeRecipientSetupAborted) { + t.Fatalf("err=%v; want %v", err, ErrAgeRecipientSetupAborted) + } +} + +func TestRunAgeSetupWizard_Option1WritesFile(t *testing.T) { + tmp := t.TempDir() + inputFile := filepath.Join(tmp, "stdin.txt") + // Option 1 -> recipient -> no more recipients. + if err := os.WriteFile(inputFile, []byte("1\nage1alpha\nn\n"), 0o600); err != nil { + t.Fatalf("write stdin: %v", err) + } + f, err := os.Open(inputFile) + if err != nil { + t.Fatalf("open stdin: %v", err) + } + defer f.Close() + + origIn := os.Stdin + t.Cleanup(func() { os.Stdin = origIn }) + os.Stdin = f + + o := newEncryptionTestOrchestrator(&config.Config{EncryptArchive: true, BaseDir: tmp}) + out, savedPath, err := o.runAgeSetupWizard(context.Background(), filepath.Join(tmp, "recipient.txt")) + if err != nil { + t.Fatalf("runAgeSetupWizard error: %v", err) + } + if savedPath == "" { + t.Fatalf("expected saved path") + } + if len(out) != 1 || out[0] != "age1alpha" { + t.Fatalf("out=%v; want %v", out, []string{"age1alpha"}) + } + data, err := os.ReadFile(savedPath) + if err != nil { + t.Fatalf("read saved: %v", err) + } + if string(data) != "age1alpha\n" { + t.Fatalf("saved content=%q; want %q", string(data), "age1alpha\n") + } +} diff --git a/internal/orchestrator/encryption_helpers_test.go b/internal/orchestrator/encryption_helpers_test.go index f472e54..91f1d09 100644 --- a/internal/orchestrator/encryption_helpers_test.go +++ b/internal/orchestrator/encryption_helpers_test.go @@ -12,6 +12,7 @@ import ( "testing" "filippo.io/age" + "github.com/tis24dev/proxsave/internal/input" "golang.org/x/crypto/ssh" ) @@ -287,31 +288,31 @@ func TestMapInputError(t *testing.T) { name: "EOF error", input: io.EOF, wantNil: false, - wantType: ErrAgeRecipientSetupAborted, + wantType: input.ErrInputAborted, }, { name: "ErrClosed", input: os.ErrClosed, wantNil: false, - wantType: ErrAgeRecipientSetupAborted, + wantType: input.ErrInputAborted, }, { name: "use of closed file", input: errors.New("use of closed file"), wantNil: false, - wantType: ErrAgeRecipientSetupAborted, + wantType: input.ErrInputAborted, }, { name: "bad file descriptor", input: errors.New("bad file descriptor"), wantNil: false, - wantType: ErrAgeRecipientSetupAborted, + wantType: input.ErrInputAborted, }, { name: "file already closed", input: errors.New("file already closed"), wantNil: false, - wantType: ErrAgeRecipientSetupAborted, + wantType: input.ErrInputAborted, }, { name: "other error passed through", @@ -322,22 +323,22 @@ func TestMapInputError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := mapInputError(tt.input) + got := input.MapInputError(tt.input) if tt.wantNil { if got != nil { - t.Errorf("mapInputError(%v) = %v; want nil", tt.input, got) + t.Errorf("MapInputError(%v) = %v; want nil", tt.input, got) } return } if got == nil { - t.Errorf("mapInputError(%v) = nil; want non-nil", tt.input) + t.Errorf("MapInputError(%v) = nil; want non-nil", tt.input) return } if tt.wantType != nil && !errors.Is(got, tt.wantType) { - t.Errorf("mapInputError(%v) = %v; want %v", tt.input, got, tt.wantType) + t.Errorf("MapInputError(%v) = %v; want %v", tt.input, got, tt.wantType) } }) } diff --git a/internal/orchestrator/encryption_interactive_test.go b/internal/orchestrator/encryption_interactive_test.go index b2b36a0..c0862c8 100644 --- a/internal/orchestrator/encryption_interactive_test.go +++ b/internal/orchestrator/encryption_interactive_test.go @@ -110,9 +110,9 @@ func TestPromptPrivateKeyRecipient_ParsesSecretKey(t *testing.T) { return []byte(id.String()), nil } - got, err := promptPrivateKeyRecipient(context.Background()) + got, err := promptPrivateKeyRecipientAge(context.Background()) if err != nil { - t.Fatalf("promptPrivateKeyRecipient error: %v", err) + t.Fatalf("promptPrivateKeyRecipientAge error: %v", err) } if got != id.Recipient().String() { t.Fatalf("recipient=%q; want %q", got, id.Recipient().String()) @@ -139,7 +139,7 @@ func TestPromptAndConfirmPassphrase_Mismatch(t *testing.T) { return next, nil } - if _, err := promptAndConfirmPassphrase(context.Background()); err == nil { + if _, err := promptAndConfirmPassphraseAge(context.Background()); err == nil { t.Fatalf("expected mismatch error, got nil") } } @@ -164,9 +164,9 @@ func TestPromptPassphraseRecipient_Success(t *testing.T) { return next, nil } - recipient, err := promptPassphraseRecipient(context.Background()) + recipient, err := promptPassphraseRecipientAge(context.Background()) if err != nil { - t.Fatalf("promptPassphraseRecipient error: %v", err) + t.Fatalf("promptPassphraseRecipientAge error: %v", err) } if !strings.HasPrefix(recipient, "age1") { t.Fatalf("recipient=%q; want age1... format", recipient) diff --git a/internal/orchestrator/prompts_cli.go b/internal/orchestrator/prompts_cli.go new file mode 100644 index 0000000..ce519fb --- /dev/null +++ b/internal/orchestrator/prompts_cli.go @@ -0,0 +1,24 @@ +package orchestrator + +import ( + "bufio" + "context" + "fmt" + "strings" + + "github.com/tis24dev/proxsave/internal/input" +) + +func promptYesNo(ctx context.Context, reader *bufio.Reader, prompt string) (bool, error) { + fmt.Print(prompt) + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return false, err + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} diff --git a/internal/orchestrator/restore.go b/internal/orchestrator/restore.go index 26379c8..dd1f5fa 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -19,6 +19,7 @@ import ( "time" "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" ) @@ -33,6 +34,7 @@ var ( serviceRetryDelay = 500 * time.Millisecond restoreLogSequence uint64 restoreGlob = filepath.Glob + prepareDecryptedBackupFunc = prepareDecryptedBackup ) func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging.Logger, version string) (err error) { @@ -41,9 +43,21 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging } done := logging.DebugStart(logger, "restore workflow (cli)", "version=%s", version) defer func() { done(err) }() + defer func() { + if err == nil { + return + } + if errors.Is(err, input.ErrInputAborted) || + errors.Is(err, ErrDecryptAborted) || + errors.Is(err, ErrAgeRecipientSetupAborted) || + errors.Is(err, context.Canceled) || + (ctx != nil && ctx.Err() != nil) { + err = ErrRestoreAborted + } + }() reader := bufio.NewReader(os.Stdin) - candidate, prepared, err := prepareDecryptedBackup(ctx, reader, cfg, logger, version, false) + candidate, prepared, err := prepareDecryptedBackupFunc(ctx, reader, cfg, logger, version, false) if err != nil { return err } @@ -64,7 +78,10 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging fmt.Println() fmt.Print("Do you want to continue anyway? This may cause system instability. (yes/no): ") - response, _ := reader.ReadString('\n') + response, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return err + } if strings.TrimSpace(strings.ToLower(response)) != "yes" { return fmt.Errorf("restore aborted due to incompatibility") } @@ -80,9 +97,9 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging } // Show restore mode selection menu - mode, err := restorePrompter.SelectRestoreMode(logger, systemType) + mode, err := restorePrompter.SelectRestoreMode(ctx, logger, systemType) if err != nil { - if err.Error() == "user cancelled" { + if errors.Is(err, ErrRestoreAborted) { return ErrRestoreAborted } return err @@ -92,9 +109,9 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging var selectedCategories []Category if mode == RestoreModeCustom { // Interactive category selection - selectedCategories, err = restorePrompter.SelectCategories(logger, availableCategories, systemType) + selectedCategories, err = restorePrompter.SelectCategories(ctx, logger, availableCategories, systemType) if err != nil { - if err.Error() == "user cancelled" { + if errors.Is(err, ErrRestoreAborted) { return ErrRestoreAborted } return err @@ -139,8 +156,11 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging ShowRestorePlan(logger, restoreConfig) // Confirm operation - confirmed, err := restorePrompter.ConfirmRestore(logger) + confirmed, err := restorePrompter.ConfirmRestore(ctx, logger) if err != nil { + if errors.Is(err, ErrRestoreAborted) { + return ErrRestoreAborted + } return err } if !confirmed { @@ -157,7 +177,10 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging logger.Warning("Failed to create safety backup: %v", err) fmt.Println() fmt.Print("Continue without safety backup? (yes/no): ") - response, _ := reader.ReadString('\n') + response, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return err + } if strings.TrimSpace(strings.ToLower(response)) != "yes" { return fmt.Errorf("restore aborted: safety backup failed") } @@ -846,11 +869,11 @@ func confirmRestoreAction(ctx context.Context, reader *bufio.Reader, cand *decry for { fmt.Print("Confirmation: ") - input, err := readLineWithContext(ctx, reader) + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { return err } - switch strings.TrimSpace(input) { + switch strings.TrimSpace(line) { case "RESTORE": return nil case "0": @@ -1190,11 +1213,11 @@ func promptClusterRestoreMode(ctx context.Context, reader *bufio.Reader) (int, e for { fmt.Print("Choice: ") - input, err := readLineWithContext(ctx, reader) + choiceLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { return 0, err } - switch strings.TrimSpace(input) { + switch strings.TrimSpace(choiceLine) { case "1": return 1, nil case "2": diff --git a/internal/orchestrator/restore_tui.go b/internal/orchestrator/restore_tui.go index b9f9a28..8acbe9f 100644 --- a/internal/orchestrator/restore_tui.go +++ b/internal/orchestrator/restore_tui.go @@ -41,6 +41,17 @@ func RunRestoreWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg } done := logging.DebugStart(logger, "restore workflow (tui)", "version=%s", version) defer func() { done(err) }() + defer func() { + if err == nil { + return + } + if errors.Is(err, ErrDecryptAborted) || + errors.Is(err, ErrAgeRecipientSetupAborted) || + errors.Is(err, context.Canceled) || + (ctx != nil && ctx.Err() != nil) { + err = ErrRestoreAborted + } + }() if strings.TrimSpace(buildSig) == "" { buildSig = "n/a" } @@ -347,7 +358,7 @@ func RunRestoreWorkflowTUI(ctx context.Context, cfg *config.Config, logger *logg } func prepareDecryptedBackupTUI(ctx context.Context, cfg *config.Config, logger *logging.Logger, version, configPath, buildSig string) (*decryptCandidate, *preparedBundle, error) { - candidate, err := runRestoreSelectionWizard(ctx, cfg, configPath, buildSig) + candidate, err := runRestoreSelectionWizard(ctx, cfg, logger, configPath, buildSig) if err != nil { return nil, nil, err } @@ -360,16 +371,22 @@ func prepareDecryptedBackupTUI(ctx context.Context, cfg *config.Config, logger * return candidate, prepared, nil } -func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPath, buildSig string) (*decryptCandidate, error) { +func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, logger *logging.Logger, configPath, buildSig string) (candidate *decryptCandidate, err error) { if ctx == nil { ctx = context.Background() } - options := buildDecryptPathOptions(cfg) + done := logging.DebugStart(logger, "restore selection wizard", "tui=true") + defer func() { done(err) }() + options := buildDecryptPathOptions(cfg, logger) if len(options) == 0 { - return nil, fmt.Errorf("no backup paths configured in backup.env") + err = fmt.Errorf("no backup paths configured in backup.env") + return nil, err + } + for _, opt := range options { + logging.DebugStep(logger, "restore selection wizard", "option label=%q path=%q rclone=%v", opt.Label, opt.Path, opt.IsRclone) } - app := tui.NewApp() + app := newTUIApp() pages := tview.NewPages() selection := &restoreSelection{} @@ -392,37 +409,42 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPa return } selectedOption := options[index] + logging.DebugStep(logger, "restore selection wizard", "selected source label=%q path=%q rclone=%v", selectedOption.Label, selectedOption.Path, selectedOption.IsRclone) pages.SwitchToPage("paths-loading") go func() { scanCtx, finish := scan.Start(ctx) defer finish() var candidates []*decryptCandidate - var err error + var scanErr error + scanDone := logging.DebugStart(logger, "scan backup source", "path=%s rclone=%v", selectedOption.Path, selectedOption.IsRclone) + defer func() { scanDone(scanErr) }() if selectedOption.IsRclone { - candidates, err = discoverRcloneBackups(scanCtx, selectedOption.Path, logging.GetDefaultLogger()) + candidates, scanErr = discoverRcloneBackups(scanCtx, selectedOption.Path, logger) } else { - candidates, err = discoverBackupCandidates(logging.GetDefaultLogger(), selectedOption.Path) + candidates, scanErr = discoverBackupCandidates(logger, selectedOption.Path) } + logging.DebugStep(logger, "scan backup source", "candidates=%d", len(candidates)) if scanCtx.Err() != nil { + scanErr = scanCtx.Err() return } app.QueueUpdateDraw(func() { - if err != nil { - message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, err) - showRestoreErrorModal(app, pages, configPath, buildSig, message, func() { - pages.SwitchToPage("paths") - }) - return - } - if len(candidates) == 0 { - message := "No backup bundles found in selected path." + if scanErr != nil { + message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, scanErr) showRestoreErrorModal(app, pages, configPath, buildSig, message, func() { pages.SwitchToPage("paths") }) return } + if len(candidates) == 0 { + message := "No backups found in selected path." + showRestoreErrorModal(app, pages, configPath, buildSig, message, func() { + pages.SwitchToPage("paths") + }) + return + } showRestoreCandidatePage(app, pages, candidates, configPath, buildSig, func(c *decryptCandidate) { selection.Candidate = c @@ -435,6 +457,7 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPa }() }) pathList.SetDoneFunc(func() { + logging.DebugStep(logger, "restore selection wizard", "cancel requested (done func)") scan.Cancel() selectionErr = ErrRestoreAborted app.Stop() @@ -455,6 +478,7 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPa form.Form.SetFocus(0) form.SetOnCancel(func() { + logging.DebugStep(logger, "restore selection wizard", "cancel requested (form)") scan.Cancel() selectionErr = ErrRestoreAborted }) @@ -470,6 +494,7 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPa loadingForm := components.NewForm(app) loadingForm.SetOnCancel(func() { + logging.DebugStep(logger, "restore selection wizard", "cancel requested (loading form)") scan.Cancel() selectionErr = ErrRestoreAborted }) @@ -482,16 +507,20 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, configPa pages.AddPage("paths-loading", loadingPage, true, false) app.SetRoot(pages, true).SetFocus(form.Form) - if err := app.Run(); err != nil { + if runErr := app.Run(); runErr != nil { + err = runErr return nil, err } if selectionErr != nil { - return nil, selectionErr + err = selectionErr + return nil, err } if selection.Candidate == nil { - return nil, ErrRestoreAborted + err = ErrRestoreAborted + return nil, err } - return selection.Candidate, nil + candidate = selection.Candidate + return candidate, nil } func showRestoreErrorModal(app *tui.App, pages *tview.Pages, configPath, buildSig, message string, onDismiss func()) { @@ -642,7 +671,7 @@ func showRestoreCandidatePage(app *tui.App, pages *tview.Pages, candidates []*de } func selectRestoreModeTUI(systemType SystemType, configPath, buildSig, backupSummary string) (RestoreMode, error) { - app := tui.NewApp() + app := newTUIApp() var selected RestoreMode var aborted bool @@ -767,7 +796,7 @@ func selectCategoriesTUI(available []Category, systemType SystemType, configPath return nil, fmt.Errorf("no categories available for this system type") } - app := tui.NewApp() + app := newTUIApp() form := components.NewForm(app) var dropdownOpen bool @@ -904,7 +933,7 @@ func promptContinueWithPBSServicesTUI(configPath, buildSig string) (bool, error) } func promptClusterRestoreModeTUI(configPath, buildSig string) (int, error) { - app := tui.NewApp() + app := newTUIApp() var choice int var aborted bool @@ -1025,7 +1054,7 @@ func showRestorePlanTUI(config *SelectiveRestoreConfig, configPath, buildSig str SetWrap(false). SetTextColor(tcell.ColorWhite) - app := tui.NewApp() + app := newTUIApp() form := components.NewForm(app) var proceed bool var aborted bool @@ -1059,7 +1088,7 @@ func showRestorePlanTUI(config *SelectiveRestoreConfig, configPath, buildSig str } func confirmRestoreTUI(configPath, buildSig string) (bool, error) { - app := tui.NewApp() + app := newTUIApp() var confirmed bool var aborted bool @@ -1115,7 +1144,7 @@ func runFullRestoreTUI(ctx context.Context, candidate *decryptCandidate, prepare return fmt.Errorf("invalid restore candidate") } - app := tui.NewApp() + app := newTUIApp() manifest := candidate.Manifest var b strings.Builder @@ -1178,7 +1207,7 @@ func runFullRestoreTUI(ctx context.Context, candidate *decryptCandidate, prepare } func promptYesNoTUI(title, configPath, buildSig, message, yesLabel, noLabel string) (bool, error) { - app := tui.NewApp() + app := newTUIApp() var result bool var cancelled bool diff --git a/internal/orchestrator/restore_tui_simulation_test.go b/internal/orchestrator/restore_tui_simulation_test.go new file mode 100644 index 0000000..ff1226f --- /dev/null +++ b/internal/orchestrator/restore_tui_simulation_test.go @@ -0,0 +1,148 @@ +package orchestrator + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func TestPromptYesNoTUI_YesReturnsTrue(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyEnter}) + + ok, err := promptYesNoTUI("Title", "/tmp/config.env", "sig", "Message", "Yes", "No") + if err != nil { + t.Fatalf("promptYesNoTUI error: %v", err) + } + if !ok { + t.Fatalf("ok=%v; want true", ok) + } +} + +func TestPromptYesNoTUI_NoReturnsFalse(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + ok, err := promptYesNoTUI("Title", "/tmp/config.env", "sig", "Message", "Yes", "No") + if err != nil { + t.Fatalf("promptYesNoTUI error: %v", err) + } + if ok { + t.Fatalf("ok=%v; want false", ok) + } +} + +func TestShowRestorePlanTUI_ContinueReturnsNil(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyEnter}) + + cfg := &SelectiveRestoreConfig{ + Mode: RestoreModeBase, + SystemType: SystemTypePVE, + SelectedCategories: []Category{ + {Name: "Alpha", Type: CategoryTypePVE, Description: "First", Paths: []string{"./etc/alpha"}}, + }, + } + if err := showRestorePlanTUI(cfg, "/tmp/config.env", "sig"); err != nil { + t.Fatalf("showRestorePlanTUI error: %v", err) + } +} + +func TestShowRestorePlanTUI_CancelReturnsAborted(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + cfg := &SelectiveRestoreConfig{ + Mode: RestoreModeBase, + SystemType: SystemTypePVE, + SelectedCategories: []Category{ + {Name: "Alpha", Type: CategoryTypePVE, Description: "First", Paths: []string{"./etc/alpha"}}, + }, + } + err := showRestorePlanTUI(cfg, "/tmp/config.env", "sig") + if err != ErrRestoreAborted { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } +} + +func TestConfirmRestoreTUI_ConfirmedAndOverwriteReturnsTrue(t *testing.T) { + restore := stubPromptYesNo(func(title, configPath, buildSig, message, yesLabel, noLabel string) (bool, error) { + return true, nil + }) + defer restore() + + withSimApp(t, []tcell.Key{tcell.KeyEnter}) + + ok, err := confirmRestoreTUI("/tmp/config.env", "sig") + if err != nil { + t.Fatalf("confirmRestoreTUI error: %v", err) + } + if !ok { + t.Fatalf("ok=%v; want true", ok) + } +} + +func TestConfirmRestoreTUI_OverwriteDeclinedReturnsFalse(t *testing.T) { + restore := stubPromptYesNo(func(title, configPath, buildSig, message, yesLabel, noLabel string) (bool, error) { + return false, nil + }) + defer restore() + + withSimApp(t, []tcell.Key{tcell.KeyEnter}) + + ok, err := confirmRestoreTUI("/tmp/config.env", "sig") + if err != nil { + t.Fatalf("confirmRestoreTUI error: %v", err) + } + if ok { + t.Fatalf("ok=%v; want false", ok) + } +} + +func TestSelectCategoriesTUI_SelectsAtLeastOne(t *testing.T) { + available := []Category{ + {Name: "Alpha", Type: CategoryTypePVE}, + } + withSimApp(t, []tcell.Key{ + tcell.KeyEnter, // open dropdown + tcell.KeyDown, // select "Yes" + tcell.KeyEnter, // close dropdown with selection + tcell.KeyTab, // Back + tcell.KeyTab, // Continue + tcell.KeyEnter, // submit + }) + + got, err := selectCategoriesTUI(available, SystemTypePVE, "/tmp/config.env", "sig") + if err != nil { + t.Fatalf("selectCategoriesTUI error: %v", err) + } + if len(got) != 1 || got[0].Name != "Alpha" { + t.Fatalf("got=%v; want [Alpha]", got) + } +} + +func TestSelectCategoriesTUI_BackReturnsErrRestoreBackToMode(t *testing.T) { + available := []Category{ + {Name: "Alpha", Type: CategoryTypePVE}, + } + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + _, err := selectCategoriesTUI(available, SystemTypePVE, "/tmp/config.env", "sig") + if err != errRestoreBackToMode { + t.Fatalf("err=%v; want %v", err, errRestoreBackToMode) + } +} + +func TestSelectCategoriesTUI_CancelReturnsAborted(t *testing.T) { + available := []Category{ + {Name: "Alpha", Type: CategoryTypePVE}, + } + withSimApp(t, []tcell.Key{ + tcell.KeyTab, // Back + tcell.KeyTab, // Continue + tcell.KeyTab, // Cancel + tcell.KeyEnter, + }) + + _, err := selectCategoriesTUI(available, SystemTypePVE, "/tmp/config.env", "sig") + if err != ErrRestoreAborted { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } +} + diff --git a/internal/orchestrator/restore_workflow_test.go b/internal/orchestrator/restore_workflow_test.go new file mode 100644 index 0000000..6358cb9 --- /dev/null +++ b/internal/orchestrator/restore_workflow_test.go @@ -0,0 +1,183 @@ +package orchestrator + +import ( + "archive/tar" + "bufio" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/logging" +) + +type fakeSystemDetector struct { + systemType SystemType +} + +func (f fakeSystemDetector) DetectCurrentSystem() SystemType { return f.systemType } + +type fakeRestorePrompter struct { + mode RestoreMode + categories []Category + confirmed bool + + modeErr error + categoriesErr error + confirmErr error +} + +func (f fakeRestorePrompter) SelectRestoreMode(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) { + return f.mode, f.modeErr +} + +func (f fakeRestorePrompter) SelectCategories(ctx context.Context, logger *logging.Logger, available []Category, systemType SystemType) ([]Category, error) { + return f.categories, f.categoriesErr +} + +func (f fakeRestorePrompter) ConfirmRestore(ctx context.Context, logger *logging.Logger) (bool, error) { + return f.confirmed, f.confirmErr +} + +func writeMinimalTar(t *testing.T, dir string) string { + t.Helper() + + path := filepath.Join(dir, "archive.tar") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create tar: %v", err) + } + defer f.Close() + + tw := tar.NewWriter(f) + defer tw.Close() + + body := []byte("hello\n") + hdr := &tar.Header{ + Name: "etc/hosts", + Mode: 0o640, + Size: int64(len(body)), + Typeflag: tar.TypeReg, + ModTime: time.Unix(1700000000, 0), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + if err := tw.Flush(); err != nil { + t.Fatalf("flush tar: %v", err) + } + return path +} + +func TestRunRestoreWorkflow_CustomModeNoCategories_Succeeds(t *testing.T) { + origCompatFS := compatFS + origPrompter := restorePrompter + origSystem := restoreSystem + origPrepare := prepareDecryptedBackupFunc + t.Cleanup(func() { + compatFS = origCompatFS + restorePrompter = origPrompter + restoreSystem = origSystem + prepareDecryptedBackupFunc = origPrepare + }) + + fakeCompat := NewFakeFS() + t.Cleanup(func() { _ = os.RemoveAll(fakeCompat.Root) }) + if err := fakeCompat.AddFile("/usr/bin/qm", []byte("x")); err != nil { + t.Fatalf("fake compat fs: %v", err) + } + compatFS = fakeCompat + + restoreSystem = fakeSystemDetector{systemType: SystemTypePVE} + restorePrompter = fakeRestorePrompter{ + mode: RestoreModeCustom, + categories: nil, + confirmed: true, + } + + tmp := t.TempDir() + archivePath := writeMinimalTar(t, tmp) + prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) { + cand := &decryptCandidate{ + DisplayBase: "test", + Manifest: &backup.Manifest{ + CreatedAt: time.Unix(1700000000, 0), + ClusterMode: "standalone", + ScriptVersion: "1.0.0", + }, + } + prepared := &preparedBundle{ + ArchivePath: archivePath, + Manifest: backup.Manifest{ArchivePath: archivePath}, + cleanup: func() {}, + } + return cand, prepared, nil + } + + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + cfg := &config.Config{BaseDir: tmp} + + if err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest"); err != nil { + t.Fatalf("RunRestoreWorkflow error: %v", err) + } +} + +func TestRunRestoreWorkflow_ConfirmFalseAborts(t *testing.T) { + origCompatFS := compatFS + origPrompter := restorePrompter + origSystem := restoreSystem + origPrepare := prepareDecryptedBackupFunc + t.Cleanup(func() { + compatFS = origCompatFS + restorePrompter = origPrompter + restoreSystem = origSystem + prepareDecryptedBackupFunc = origPrepare + }) + + fakeCompat := NewFakeFS() + t.Cleanup(func() { _ = os.RemoveAll(fakeCompat.Root) }) + if err := fakeCompat.AddFile("/usr/bin/qm", []byte("x")); err != nil { + t.Fatalf("fake compat fs: %v", err) + } + compatFS = fakeCompat + + restoreSystem = fakeSystemDetector{systemType: SystemTypePVE} + restorePrompter = fakeRestorePrompter{ + mode: RestoreModeCustom, + categories: nil, + confirmed: false, + } + + tmp := t.TempDir() + archivePath := writeMinimalTar(t, tmp) + prepareDecryptedBackupFunc = func(ctx context.Context, reader *bufio.Reader, cfg *config.Config, logger *logging.Logger, version string, requireEncrypted bool) (*decryptCandidate, *preparedBundle, error) { + cand := &decryptCandidate{ + DisplayBase: "test", + Manifest: &backup.Manifest{ + CreatedAt: time.Unix(1700000000, 0), + ClusterMode: "standalone", + ScriptVersion: "1.0.0", + }, + } + prepared := &preparedBundle{ + ArchivePath: archivePath, + Manifest: backup.Manifest{ArchivePath: archivePath}, + cleanup: func() {}, + } + return cand, prepared, nil + } + + logger := logging.New(logging.GetDefaultLogger().GetLevel(), false) + cfg := &config.Config{BaseDir: tmp} + + err := RunRestoreWorkflow(context.Background(), cfg, logger, "vtest") + if err != ErrRestoreAborted { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } +} diff --git a/internal/orchestrator/selective.go b/internal/orchestrator/selective.go index 0ab40ba..f46c96a 100644 --- a/internal/orchestrator/selective.go +++ b/internal/orchestrator/selective.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bufio" "context" + "errors" "fmt" "os" "sort" @@ -11,6 +12,7 @@ import ( "strings" "github.com/tis24dev/proxsave/internal/backup" + "github.com/tis24dev/proxsave/internal/input" "github.com/tis24dev/proxsave/internal/logging" ) @@ -136,7 +138,7 @@ func pathMatchesPattern(archivePath, pattern string) bool { } // ShowRestoreModeMenu displays the restore mode selection menu -func ShowRestoreModeMenu(logger *logging.Logger, systemType SystemType) (RestoreMode, error) { +func ShowRestoreModeMenu(ctx context.Context, logger *logging.Logger, systemType SystemType) (RestoreMode, error) { reader := bufio.NewReader(os.Stdin) fmt.Println() @@ -156,32 +158,35 @@ func ShowRestoreModeMenu(logger *logging.Logger, systemType SystemType) (Restore fmt.Println(" [0] Cancel") fmt.Print("Choice: ") - input, err := reader.ReadString('\n') - if err != nil { - return "", err - } + for { + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) { + return "", ErrRestoreAborted + } + return "", err + } - choice := strings.TrimSpace(input) - - switch choice { - case "1": - return RestoreModeFull, nil - case "2": - return RestoreModeStorage, nil - case "3": - return RestoreModeBase, nil - case "4": - return RestoreModeCustom, nil - case "0": - return "", fmt.Errorf("user cancelled") - default: - fmt.Println("Invalid choice. Please try again.") - return ShowRestoreModeMenu(logger, systemType) + switch strings.TrimSpace(line) { + case "1": + return RestoreModeFull, nil + case "2": + return RestoreModeStorage, nil + case "3": + return RestoreModeBase, nil + case "4": + return RestoreModeCustom, nil + case "0": + return "", ErrRestoreAborted + default: + fmt.Println("Invalid choice. Please try again.") + fmt.Print("Choice: ") + } } } // ShowCategorySelectionMenu displays an interactive category selection menu -func ShowCategorySelectionMenu(logger *logging.Logger, availableCategories []Category, systemType SystemType) ([]Category, error) { +func ShowCategorySelectionMenu(ctx context.Context, logger *logging.Logger, availableCategories []Category, systemType SystemType) ([]Category, error) { reader := bufio.NewReader(os.Stdin) // Filter categories by system type @@ -237,12 +242,15 @@ func ShowCategorySelectionMenu(logger *logging.Logger, availableCategories []Cat fmt.Println(" 0 - Cancel") fmt.Print("\nChoice: ") - input, err := reader.ReadString('\n') + inputLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { + if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) { + return nil, ErrRestoreAborted + } return nil, err } - choice := strings.TrimSpace(strings.ToLower(input)) + choice := strings.TrimSpace(strings.ToLower(inputLine)) switch choice { case "a": @@ -277,7 +285,7 @@ func ShowCategorySelectionMenu(logger *logging.Logger, availableCategories []Cat return selectedCategories, nil case "0": - return nil, fmt.Errorf("user cancelled") + return nil, ErrRestoreAborted default: // Try to parse as a number @@ -405,18 +413,21 @@ func ShowRestorePlan(logger *logging.Logger, config *SelectiveRestoreConfig) { } // ConfirmRestoreOperation asks for user confirmation before proceeding -func ConfirmRestoreOperation(logger *logging.Logger) (bool, error) { +func ConfirmRestoreOperation(ctx context.Context, logger *logging.Logger) (bool, error) { reader := bufio.NewReader(os.Stdin) for { fmt.Println("═══════════════════════════════════════════════════════════════") fmt.Print("Type 'RESTORE' to proceed or 'cancel' to abort: ") - input, err := reader.ReadString('\n') + inputLine, err := input.ReadLineWithContext(ctx, reader) if err != nil { + if errors.Is(err, input.ErrInputAborted) || errors.Is(err, context.Canceled) { + return false, ErrRestoreAborted + } return false, err } - response := strings.TrimSpace(input) + response := strings.TrimSpace(inputLine) if response == "RESTORE" { return true, nil diff --git a/internal/orchestrator/selective_additional_test.go b/internal/orchestrator/selective_additional_test.go index e083cc9..e6fac52 100644 --- a/internal/orchestrator/selective_additional_test.go +++ b/internal/orchestrator/selective_additional_test.go @@ -3,6 +3,7 @@ package orchestrator import ( "archive/tar" "bytes" + "context" "os" "testing" @@ -85,7 +86,7 @@ func TestConfirmRestoreOperation(t *testing.T) { os.Stdin = r defer r.Close() - got, err := ConfirmRestoreOperation(logger) + got, err := ConfirmRestoreOperation(context.Background(), logger) if err != nil { t.Fatalf("ConfirmRestoreOperation returned error: %v", err) } diff --git a/internal/orchestrator/tui_hooks.go b/internal/orchestrator/tui_hooks.go new file mode 100644 index 0000000..fe2cedb --- /dev/null +++ b/internal/orchestrator/tui_hooks.go @@ -0,0 +1,7 @@ +package orchestrator + +import "github.com/tis24dev/proxsave/internal/tui" + +// newTUIApp is an injection point for tests. Production uses tui.NewApp. +var newTUIApp = tui.NewApp + diff --git a/internal/orchestrator/tui_simulation_test.go b/internal/orchestrator/tui_simulation_test.go new file mode 100644 index 0000000..7f52aef --- /dev/null +++ b/internal/orchestrator/tui_simulation_test.go @@ -0,0 +1,121 @@ +package orchestrator + +import ( + "testing" + "time" + + "github.com/gdamore/tcell/v2" + + "github.com/tis24dev/proxsave/internal/tui" +) + +type simKey struct { + Key tcell.Key + R rune + Mod tcell.ModMask +} + +func withSimAppSequence(t *testing.T, keys []simKey) { + t.Helper() + + orig := newTUIApp + screen := tcell.NewSimulationScreen("UTF-8") + if err := screen.Init(); err != nil { + t.Fatalf("screen.Init: %v", err) + } + screen.SetSize(120, 40) + + newTUIApp = func() *tui.App { + app := tui.NewApp() + app.SetScreen(screen) + + go func() { + // Wait for app.Run() to start event processing. + time.Sleep(50 * time.Millisecond) + for _, k := range keys { + mod := k.Mod + if mod == 0 { + mod = tcell.ModNone + } + screen.InjectKey(k.Key, k.R, mod) + time.Sleep(10 * time.Millisecond) + } + }() + return app +} + + t.Cleanup(func() { + newTUIApp = orig + }) +} + +func withSimApp(t *testing.T, keys []tcell.Key) { + t.Helper() + seq := make([]simKey, 0, len(keys)) + for _, k := range keys { + seq = append(seq, simKey{Key: k}) + } + withSimAppSequence(t, seq) +} + +func TestPromptOverwriteAction_SelectsOverwrite(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyEnter}) + + got, err := promptOverwriteAction("/tmp/existing", "file", "", "/tmp/config.env", "sig") + if err != nil { + t.Fatalf("promptOverwriteAction error: %v", err) + } + if got != pathActionOverwrite { + t.Fatalf("choice=%q; want %q", got, pathActionOverwrite) + } +} + +func TestPromptNewPathInput_ContinueReturnsDefault(t *testing.T) { + // Move focus to Continue button then submit. + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + got, err := promptNewPathInput("/tmp/newpath", "/tmp/config.env", "sig") + if err != nil { + t.Fatalf("promptNewPathInput error: %v", err) + } + if got != "/tmp/newpath" { + t.Fatalf("path=%q; want %q", got, "/tmp/newpath") + } +} + +func TestSelectRestoreModeTUI_SelectsStorage(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyDown, tcell.KeyEnter}) + + mode, err := selectRestoreModeTUI(SystemTypePVE, "/tmp/config.env", "sig", "backup") + if err != nil { + t.Fatalf("selectRestoreModeTUI error: %v", err) + } + if mode != RestoreModeStorage { + t.Fatalf("mode=%q; want %q", mode, RestoreModeStorage) + } +} + +func TestPromptClusterRestoreModeTUI_SelectsRecovery(t *testing.T) { + withSimApp(t, []tcell.Key{tcell.KeyDown, tcell.KeyEnter}) + + choice, err := promptClusterRestoreModeTUI("/tmp/config.env", "sig") + if err != nil { + t.Fatalf("promptClusterRestoreModeTUI error: %v", err) + } + if choice != 2 { + t.Fatalf("choice=%d; want 2", choice) + } +} + +func TestPromptClusterRestoreModeTUI_CancelAborts(t *testing.T) { + // Switch focus to the Cancel button then submit. + withSimApp(t, []tcell.Key{tcell.KeyTab, tcell.KeyEnter}) + + _, err := promptClusterRestoreModeTUI("/tmp/config.env", "sig") + if err == nil { + t.Fatalf("expected abort error") + } + if err != ErrRestoreAborted { + t.Fatalf("err=%v; want %v", err, ErrRestoreAborted) + } +} diff --git a/internal/support/support.go b/internal/support/support.go new file mode 100644 index 0000000..d66172e --- /dev/null +++ b/internal/support/support.go @@ -0,0 +1,231 @@ +package support + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/input" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/orchestrator" + "github.com/tis24dev/proxsave/internal/types" +) + +type Meta struct { + GitHubUser string + IssueID string +} + +// RunIntro prompts for consent and GitHub metadata. +// ok=false means the user declined or aborted; interrupted=true means context cancel / Ctrl+C. +func RunIntro(ctx context.Context, bootstrap *logging.BootstrapLogger) (meta Meta, ok bool, interrupted bool) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println() + fmt.Println("\033[32m================================================\033[0m") + fmt.Println("\033[32m SUPPORT & ASSISTANCE MODE\033[0m") + fmt.Println("\033[32m================================================\033[0m") + fmt.Println() + fmt.Println("This mode will send the ProxSave log to the developer for debugging.") + fmt.Println("\033[33mIf your log contains personal or sensitive information, it will be shared.\033[0m") + fmt.Println() + + accepted, err := promptYesNoSupport(ctx, reader, "Do you accept and continue? [y/N]: ") + if err != nil { + if errors.Is(err, input.ErrInputAborted) || ctx.Err() == context.Canceled { + bootstrap.Warning("Support mode interrupted by signal") + return Meta{}, false, true + } + bootstrap.Error("ERROR: %v", err) + return Meta{}, false, false + } + if !accepted { + bootstrap.Warning("Support mode aborted by user (consent not granted)") + return Meta{}, false, false + } + + fmt.Println() + fmt.Println("Before proceeding, you must have an open GitHub issue for this problem.") + fmt.Println("Emails without a corresponding GitHub issue will not be analyzed.") + fmt.Println() + + hasIssue, err := promptYesNoSupport(ctx, reader, "Do you confirm that you have already opened a GitHub issue? [y/N]: ") + if err != nil { + if errors.Is(err, input.ErrInputAborted) || ctx.Err() == context.Canceled { + bootstrap.Warning("Support mode interrupted by signal") + return Meta{}, false, true + } + bootstrap.Error("ERROR: %v", err) + return Meta{}, false, false + } + if !hasIssue { + bootstrap.Warning("Support mode aborted: please open a GitHub issue first") + return Meta{}, false, false + } + + // GitHub nickname + for { + fmt.Print("Enter your GitHub nickname: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + if errors.Is(err, input.ErrInputAborted) || ctx.Err() == context.Canceled { + bootstrap.Warning("Support mode interrupted by signal") + return Meta{}, false, true + } + bootstrap.Error("ERROR: Failed to read input: %v", err) + return Meta{}, false, false + } + nickname := strings.TrimSpace(line) + if nickname == "" { + fmt.Println("GitHub nickname cannot be empty. Please try again.") + continue + } + meta.GitHubUser = nickname + break + } + + // GitHub issue number + for { + fmt.Print("Enter the GitHub issue number in the format #1234: ") + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + if errors.Is(err, input.ErrInputAborted) || ctx.Err() == context.Canceled { + bootstrap.Warning("Support mode interrupted by signal") + return Meta{}, false, true + } + bootstrap.Error("ERROR: Failed to read input: %v", err) + return Meta{}, false, false + } + issue := strings.TrimSpace(line) + if issue == "" { + fmt.Println("Issue number cannot be empty. Please try again.") + continue + } + if !strings.HasPrefix(issue, "#") || len(issue) < 2 { + fmt.Println("Issue must start with '#' and contain a numeric ID, for example: #1234.") + continue + } + if _, err := strconv.Atoi(issue[1:]); err != nil { + fmt.Println("Issue must be in the format #1234 with a numeric ID. Please try again.") + continue + } + meta.IssueID = issue + break + } + + fmt.Println() + fmt.Println("Support mode confirmed.") + fmt.Println("The run will execute in DEBUG mode and a support email with the full log will be sent to github-support@tis24.it at the end.") + fmt.Println() + + return meta, true, false +} + +func promptYesNoSupport(ctx context.Context, reader *bufio.Reader, prompt string) (bool, error) { + for { + fmt.Print(prompt) + line, err := input.ReadLineWithContext(ctx, reader) + if err != nil { + return false, err + } + answer := strings.TrimSpace(strings.ToLower(line)) + if answer == "y" || answer == "yes" { + return true, nil + } + if answer == "" || answer == "n" || answer == "no" { + return false, nil + } + fmt.Println("Please answer with 'y' or 'n'.") + } +} + +// BuildSupportStats builds a minimal BackupStats suitable for support email/log attachment. +func BuildSupportStats(logger *logging.Logger, hostname string, proxmoxType types.ProxmoxType, proxmoxVersion, toolVersion string, startTime, endTime time.Time, exitCode int, mode string) *orchestrator.BackupStats { + if logger == nil { + return nil + } + logPath := logger.GetLogFilePath() + stats := &orchestrator.BackupStats{ + Hostname: hostname, + ProxmoxType: proxmoxType, + ProxmoxVersion: proxmoxVersion, + Version: toolVersion, + ScriptVersion: toolVersion, + Timestamp: startTime, + StartTime: startTime, + EndTime: endTime, + Duration: endTime.Sub(startTime), + LogFilePath: logPath, + ExitCode: exitCode, + LocalStatus: "ok", + } + if exitCode != 0 { + stats.LocalStatus = "error" + } + if strings.TrimSpace(mode) != "" { + stats.LocalStatusSummary = fmt.Sprintf("Support wrapper mode=%s", strings.TrimSpace(mode)) + } else { + stats.LocalStatusSummary = "Support wrapper" + } + return stats +} + +func SendEmail(ctx context.Context, cfg *config.Config, logger *logging.Logger, proxmoxType types.ProxmoxType, stats *orchestrator.BackupStats, meta Meta, buildSignature string) { + if stats == nil { + logging.Warning("Support mode: cannot send support email because stats are nil") + return + } + + subject := "SUPPORT REQUEST" + if strings.TrimSpace(meta.GitHubUser) != "" || strings.TrimSpace(meta.IssueID) != "" { + subjectParts := []string{"SUPPORT REQUEST"} + if strings.TrimSpace(meta.GitHubUser) != "" { + subjectParts = append(subjectParts, fmt.Sprintf("Nickname: %s", strings.TrimSpace(meta.GitHubUser))) + } + if strings.TrimSpace(meta.IssueID) != "" { + subjectParts = append(subjectParts, fmt.Sprintf("Issue: %s", strings.TrimSpace(meta.IssueID))) + } + subject = strings.Join(subjectParts, " - ") + } + + if sig := strings.TrimSpace(buildSignature); sig != "" { + subject = fmt.Sprintf("%s - Build: %s", subject, sig) + } + + from := "" + if cfg != nil { + from = cfg.EmailFrom + } + + emailConfig := notify.EmailConfig{ + Enabled: true, + DeliveryMethod: notify.EmailDeliverySendmail, + FallbackSendmail: false, + AttachLogFile: true, + Recipient: "github-support@tis24.it", + From: from, + SubjectOverride: subject, + } + + emailNotifier, err := notify.NewEmailNotifier(emailConfig, proxmoxType, logger) + if err != nil { + logging.Warning("Support mode: failed to initialize support email notifier: %v", err) + return + } + + adapter := orchestrator.NewNotificationAdapter(emailNotifier, logger) + if err := adapter.Notify(ctx, stats); err != nil { + logging.Critical("Support mode: FAILED to send support email: %v", err) + fmt.Println("\033[33m⚠️ CRITICAL: Support email failed to send!\033[0m") + return + } + + logging.Info("Support mode: support email handed off to local MTA for github-support@tis24.it (check mailq and /var/log/mail.log for delivery)") +} diff --git a/internal/tui/abort_context.go b/internal/tui/abort_context.go new file mode 100644 index 0000000..fe1e5ea --- /dev/null +++ b/internal/tui/abort_context.go @@ -0,0 +1,40 @@ +package tui + +import ( + "context" + "sync" +) + +var ( + abortContextMu sync.RWMutex + abortContext context.Context +) + +// SetAbortContext registers a process-wide context used to stop any running TUI +// app (tview) when the context is canceled (e.g. Ctrl+C). +// +// This is intentionally global so all TUIs behave consistently without each +// wizard needing bespoke signal handling. +func SetAbortContext(ctx context.Context) { + abortContextMu.Lock() + abortContext = ctx + abortContextMu.Unlock() +} + +func getAbortContext() context.Context { + abortContextMu.RLock() + ctx := abortContext + abortContextMu.RUnlock() + return ctx +} + +func bindAbortContext(app *App) { + ctx := getAbortContext() + if ctx == nil { + return + } + go func() { + <-ctx.Done() + app.Stop() + }() +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 867f47a..0e4737d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -32,6 +32,7 @@ func NewApp() *App { tview.Styles.InverseTextColor = tcell.ColorBlack tview.Styles.ContrastSecondaryTextColor = tcell.ColorWhite + bindAbortContext(app) return app }