From 29b6a1173530c3768ad64e2dc0cf1106c8c88360 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 12:57:44 +0100 Subject: [PATCH 1/6] Add detailed debug logging to backup source selection Enhanced the backup source selection and scanning logic with granular debug logging for better traceability. Refactored functions to accept a logger parameter, updated TUI workflows to propagate logging, and improved error handling and reporting throughout the backup discovery and extraction processes. Adjusted tests to match new function signatures. --- internal/orchestrator/.backup.lock | 4 +- internal/orchestrator/backup_sources.go | 108 +++++++++++++++++-- internal/orchestrator/backup_sources_test.go | 10 +- internal/orchestrator/decrypt.go | 28 ++++- internal/orchestrator/decrypt_test.go | 2 +- internal/orchestrator/decrypt_tui.go | 47 +++++--- internal/orchestrator/restore_tui.go | 44 +++++--- 7 files changed, 196 insertions(+), 47 deletions(-) diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 198d2b9..c08fc52 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=1146235 +pid=24149 host=pve -time=2026-01-11T12:27:12+01:00 +time=2026-01-16T11:36:36+01:00 diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index 1d9bf4d..2283d69 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -25,55 +25,90 @@ 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=%v cloud=%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 != "" { + logging.DebugStep(logger, "build backup source options", "add secondary path=%q", clean) options = append(options, decryptPathOption{ Label: "Secondary backups", Path: clean, }) + } else { + logging.DebugStep(logger, "build backup source options", "skip secondary (enabled but path empty)") } + } else { + logging.DebugStep(logger, "build backup source options", "skip secondary (disabled)") } if cfg.CloudEnabled { 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) + timeout := 10 * time.Second + scanCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - candidates, err := discoverRcloneBackups(scanCtx, cloudRoot, nil) + prescanDone := logging.DebugStart(logger, "cloud prescan", "mode=rclone root=%s timeout=%s", cloudRoot, timeout) + candidates, err := discoverRcloneBackups(scanCtx, cloudRoot, logger) + prescanDone(err) if err == nil && len(candidates) > 0 { + logging.DebugStep(logger, "build backup source options", "add cloud rclone (candidates=%d)", len(candidates)) options = append(options, decryptPathOption{ Label: "Cloud backups (rclone)", Path: cloudRoot, IsRclone: true, }) + } else if err != nil { + logging.DebugStep(logger, "build backup source options", "skip cloud rclone (scan error=%v)", err) + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud rclone (no candidates)") } } else if isLocalFilesystemPath(cloudRoot) { // Local filesystem mount // Pre-scan: verify backups exist before adding option - candidates, err := discoverBackupCandidates(nil, cloudRoot) + prescanDone := logging.DebugStart(logger, "cloud prescan", "mode=filesystem root=%s", cloudRoot) + candidates, err := discoverBackupCandidates(logger, cloudRoot) + prescanDone(err) if err == nil && len(candidates) > 0 { + logging.DebugStep(logger, "build backup source options", "add cloud filesystem (candidates=%d)", len(candidates)) options = append(options, decryptPathOption{ Label: "Cloud backups", Path: cloudRoot, IsRclone: false, }) + } else if err != nil { + logging.DebugStep(logger, "build backup source options", "skip cloud filesystem (scan error=%v)", err) + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud filesystem (no candidates)") } + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud (unrecognized root)") } + } else { + logging.DebugStep(logger, "build backup source options", "skip cloud (disabled)") } + logging.DebugStep(logger, "build backup source options", "final options=%d", len(options)) return options } @@ -89,6 +124,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 + backup indicator (-backup- or proxmox-backup-)") logDebug(logger, "Cloud (rclone): listing backups under %s", fullPath) logDebug(logger, "Cloud (rclone): executing: rclone lsf %s", fullPath) @@ -98,15 +134,22 @@ 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 + nonBundleEntries := 0 + nonBackupEntries := 0 + manifestErrors := 0 + logDebug(logger, "Cloud (rclone): scanned %d entries from rclone lsf output", totalEntries) for _, line := range lines { filename := strings.TrimSpace(line) if filename == "" { + emptyEntries++ continue } @@ -115,12 +158,14 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi // - *.tar.{gz|xz|zst}.bundle.tar (plain bundle) // - *.tar.{gz|xz|zst}.age.bundle.tar (age-encrypted bundle) if !strings.Contains(filename, ".bundle.tar") { + nonBundleEntries++ continue } // Must contain backup indicator in filename isBackup := strings.Contains(filename, "-backup-") || strings.HasPrefix(filename, "proxmox-backup-") if !isBackup { + nonBackupEntries++ logDebug(logger, "Skipping non-backup bundle: %s", filename) continue } @@ -134,6 +179,7 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi manifest, err := inspectRcloneBundleManifest(ctx, remoteFile, logger) if err != nil { + manifestErrors++ logWarning(logger, "Skipping rclone bundle %s: %v", filename, err) continue } @@ -148,6 +194,17 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi logDebug(logger, "Cloud (rclone): accepted backup bundle: %s", filename) } + logging.DebugStep( + logger, + "discover rclone backups", + "summary entries=%d empty=%d non_bundle=%d non_backup=%d manifest_errors=%d accepted=%d", + totalEntries, + emptyEntries, + nonBundleEntries, + nonBackupEntries, + manifestErrors, + len(candidates), + ) logDebug(logger, "Cloud (rclone): scanned %d files, found %d valid backup bundles", len(lines), len(candidates)) logDebug(logger, "Cloud (rclone): discovered %d bundle candidate(s) in %s", len(candidates), fullPath) @@ -167,21 +224,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 +261,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 +312,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..6a15b17 100644 --- a/internal/orchestrator/backup_sources_test.go +++ b/internal/orchestrator/backup_sources_test.go @@ -122,7 +122,7 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "gdrive" cfg.CloudRemotePath = "pbs-backups/server1" - opts := buildDecryptPathOptions(cfg) + opts := buildDecryptPathOptions(cfg, nil) // 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 { @@ -143,7 +143,7 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "gdrive:pbs-backups" cfg.CloudRemotePath = "server1" - opts := buildDecryptPathOptions(cfg) + opts := buildDecryptPathOptions(cfg, nil) // 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 { @@ -157,7 +157,7 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "/mnt/cloud/backups" cfg.CloudRemotePath = "server1" - opts := buildDecryptPathOptions(cfg) + opts := buildDecryptPathOptions(cfg, nil) // 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 { @@ -170,7 +170,7 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudEnabled = false cfg.CloudRemote = "gdrive:pbs-backups" - opts := buildDecryptPathOptions(cfg) + opts := buildDecryptPathOptions(cfg, nil) if len(opts) != 2 { t.Fatalf("len(options) = %d; want 2 (local + secondary)", len(opts)) } @@ -187,7 +187,7 @@ func TestBuildDecryptPathOptions_FullConfigOrder(t *testing.T) { CloudRemotePath: "pbs-backups/server1", } - opts := buildDecryptPathOptions(cfg) + opts := buildDecryptPathOptions(cfg, nil) // 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 { diff --git a/internal/orchestrator/decrypt.go b/internal/orchestrator/decrypt.go index 8c69ad5..6183280 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -175,7 +175,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") } @@ -345,6 +345,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 { @@ -540,10 +541,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(cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } @@ -649,7 +650,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 +663,7 @@ func extractBundleToWorkdir(bundlePath, workDir string) (staged stagedFiles, err defer file.Close() tr := tar.NewReader(file) + extracted := 0 for { hdr, err := tr.Next() @@ -693,35 +699,47 @@ 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) + return copyRawArtifactsToWorkdirWithLogger(cand, workDir, nil) +} + +func copyRawArtifactsToWorkdirWithLogger(cand *decryptCandidate, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { + done := logging.DebugStart(logger, "stage raw artifacts", "archive=%s workdir=%s", cand.RawArchivePath, workDir) defer func() { done(err) }() archiveDest := filepath.Join(workDir, filepath.Base(cand.RawArchivePath)) + 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) } metadataDest := filepath.Join(workDir, filepath.Base(cand.RawMetadataPath)) + 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) } checksumDest := filepath.Join(workDir, filepath.Base(cand.RawChecksumPath)) + logging.DebugStep(logger, "stage raw artifacts", "copy checksum to %s", checksumDest) if err := copyFile(restoreFS, cand.RawChecksumPath, checksumDest); err != nil { return stagedFiles{}, fmt.Errorf("copy checksum: %w", err) } diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index 0a60ff4..1033bbc 100644 --- a/internal/orchestrator/decrypt_test.go +++ b/internal/orchestrator/decrypt_test.go @@ -163,7 +163,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", diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index e25312b..738b929 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() 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 } @@ -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(cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } diff --git a/internal/orchestrator/restore_tui.go b/internal/orchestrator/restore_tui.go index b9f9a28..c4096f7 100644 --- a/internal/orchestrator/restore_tui.go +++ b/internal/orchestrator/restore_tui.go @@ -347,7 +347,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,13 +360,19 @@ 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() @@ -392,25 +398,30 @@ 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) + if scanErr != nil { + message := fmt.Sprintf("Failed to inspect %s: %v", selectedOption.Path, scanErr) showRestoreErrorModal(app, pages, configPath, buildSig, message, func() { pages.SwitchToPage("paths") }) @@ -435,6 +446,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 +467,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 +483,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 +496,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()) { From eec8e69a0a0945572943539509d841ec3c582065 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 12:59:13 +0100 Subject: [PATCH 2/6] Refactor interactive input handling and prompt APIs Moves interactive input logic (line/password reading, error mapping) to a new internal/input package, replacing duplicated code in CLI and orchestrator modules. Refactors prompt and prompter APIs to accept context for cancellation, updates all usages and tests, and improves error normalization for user-aborted input. Adds process-wide abort context support for TUI. This change improves consistency, testability, and graceful shutdown handling across all interactive workflows. --- cmd/proxsave/env_migration.go | 9 +- cmd/proxsave/helpers_test.go | 17 +-- cmd/proxsave/main.go | 25 ++-- cmd/proxsave/prompts.go | 50 +------ cmd/proxsave/prompts_test.go | 10 +- internal/backup/collector.go | 2 +- internal/input/input.go | 82 ++++++++++++ internal/orchestrator/.backup.lock | 4 +- .../orchestrator/additional_helpers_test.go | 10 +- internal/orchestrator/decrypt.go | 57 ++++---- internal/orchestrator/deps.go | 18 +-- internal/orchestrator/deps_additional_test.go | 6 +- internal/orchestrator/deps_test.go | 6 +- internal/orchestrator/encryption.go | 122 +++++------------- .../orchestrator/encryption_helpers_test.go | 19 +-- .../encryption_interactive_test.go | 10 +- internal/orchestrator/prompts_cli.go | 24 ++++ internal/orchestrator/restore.go | 44 +++++-- internal/orchestrator/restore_tui.go | 11 ++ internal/orchestrator/selective.go | 67 ++++++---- .../orchestrator/selective_additional_test.go | 3 +- internal/tui/abort_context.go | 40 ++++++ internal/tui/app.go | 1 + 23 files changed, 371 insertions(+), 266 deletions(-) create mode 100644 internal/input/input.go create mode 100644 internal/orchestrator/prompts_cli.go create mode 100644 internal/tui/abort_context.go 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/main.go b/cmd/proxsave/main.go index cb7ddea..39697c0 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -23,11 +23,13 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/identity" + "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/security" "github.com/tis24dev/proxsave/internal/storage" + "github.com/tis24dev/proxsave/internal/tui" "github.com/tis24dev/proxsave/internal/types" buildinfo "github.com/tis24dev/proxsave/internal/version" ) @@ -87,6 +89,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 +97,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 { @@ -1473,9 +1476,9 @@ func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, ar 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]: ") + accepted, err := promptYesNoSupport(ctx, reader, "Do you accept and continue? [y/N]: ") if err != nil { - if ctx.Err() == context.Canceled { + if errors.Is(err, errInteractiveAborted) || ctx.Err() == context.Canceled { bootstrap.Warning("Support mode interrupted by signal") return false, true } @@ -1492,9 +1495,9 @@ func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, ar 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]: ") + hasIssue, err := promptYesNoSupport(ctx, reader, "Do you confirm that you have already opened a GitHub issue? [y/N]: ") if err != nil { - if ctx.Err() == context.Canceled { + if errors.Is(err, errInteractiveAborted) || ctx.Err() == context.Canceled { bootstrap.Warning("Support mode interrupted by signal") return false, true } @@ -1509,9 +1512,9 @@ func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, ar // GitHub nickname for { fmt.Print("Enter your GitHub nickname: ") - line, err := reader.ReadString('\n') + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - if ctx.Err() == context.Canceled { + if errors.Is(err, errInteractiveAborted) || ctx.Err() == context.Canceled { bootstrap.Warning("Support mode interrupted by signal") return false, true } @@ -1530,9 +1533,9 @@ func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, ar // GitHub issue number for { fmt.Print("Enter the GitHub issue number in the format #1234: ") - line, err := reader.ReadString('\n') + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { - if ctx.Err() == context.Canceled { + if errors.Is(err, errInteractiveAborted) || ctx.Err() == context.Canceled { bootstrap.Warning("Support mode interrupted by signal") return false, true } @@ -1564,10 +1567,10 @@ func runSupportIntro(ctx context.Context, bootstrap *logging.BootstrapLogger, ar return true, false } -func promptYesNoSupport(reader *bufio.Reader, prompt string) (bool, error) { +func promptYesNoSupport(ctx context.Context, reader *bufio.Reader, prompt string) (bool, error) { for { fmt.Print(prompt) - line, err := reader.ReadString('\n') + line, err := input.ReadLineWithContext(ctx, reader) if err != nil { return false, err } 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/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/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/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index c08fc52..4837990 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=24149 +pid=66787 host=pve -time=2026-01-16T11:36:36+01:00 +time=2026-01-16T12:50:30+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/decrypt.go b/internal/orchestrator/decrypt.go index 6183280..932c43e 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -19,6 +19,7 @@ import ( "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 +78,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) @@ -285,11 +294,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 } @@ -420,11 +429,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 } @@ -448,11 +457,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 } @@ -753,7 +762,7 @@ func copyRawArtifactsToWorkdirWithLogger(cand *decryptCandidate, workDir string, 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 @@ -888,27 +897,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/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_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..980a04d 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" ) @@ -41,6 +42,18 @@ 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) @@ -64,7 +77,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 +96,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 +108,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 +155,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 +176,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 +868,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 +1212,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 c4096f7..a693982 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" } 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/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 } From 8d0df2b2eb50023f10edf91b0dfab6bf7b668fae Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 13:35:33 +0100 Subject: [PATCH 3/6] Refactor support mode logic into internal/support package Moved support mode interactive prompts and email logic from main.go to a new internal/support package for better modularity and maintainability. Updated CLI help and documentation to clarify that --support is available for both standard backup and --restore. Adjusted main.go to use the new support package and improved handling of support metadata and stats during restore workflows. --- cmd/proxsave/install.go | 2 +- cmd/proxsave/main.go | 208 ++++---------------------- docs/CLI_REFERENCE.md | 6 +- internal/cli/args.go | 2 +- internal/orchestrator/.backup.lock | 4 +- internal/support/support.go | 231 +++++++++++++++++++++++++++++ 6 files changed, 269 insertions(+), 184 deletions(-) create mode 100644 internal/support/support.go 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 39697c0..4e9fc18 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "context" "errors" "fmt" @@ -23,12 +22,12 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/environment" "github.com/tis24dev/proxsave/internal/identity" - "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/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" @@ -127,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") @@ -150,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() } } @@ -405,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) @@ -606,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 @@ -742,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() { @@ -752,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()) } @@ -763,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() { @@ -773,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()) } @@ -1412,179 +1435,10 @@ 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(" --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() } -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() - 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(ctx, reader, "Do you accept and continue? [y/N]: ") - if err != nil { - if errors.Is(err, errInteractiveAborted) || 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(ctx, reader, "Do you confirm that you have already opened a GitHub issue? [y/N]: ") - if err != nil { - if errors.Is(err, errInteractiveAborted) || 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 := input.ReadLineWithContext(ctx, reader) - if err != nil { - if errors.Is(err, errInteractiveAborted) || 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 := input.ReadLineWithContext(ctx, reader) - if err != nil { - if errors.Is(err, errInteractiveAborted) || 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(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'.") - } -} - // checkGoRuntimeVersion ensures the running binary was built with at least the specified Go version (semver: major.minor.patch). func checkGoRuntimeVersion(min string) error { rt := runtime.Version() // e.g., "go1.25.4" 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/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/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 4837990..329d867 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=66787 +pid=85844 host=pve -time=2026-01-16T12:50:30+01:00 +time=2026-01-16T13:24:38+01:00 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)") +} From 00b2d1fe6263aa624b2457e676907473b9c99b0b Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 14:50:17 +0100 Subject: [PATCH 4/6] Improved test coverage for restore/decrypt (bundle + raw, deterministic rclone) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added and updated tests to validate the new “always scan bundle and non-bundle” behavior (including rclone), made candidate ordering deterministic, and removed/adjusted legacy assumptions about cloud pre-scan hiding and mandatory raw checksum sidecars. --- internal/orchestrator/.backup.lock | 4 +- internal/orchestrator/backup_sources.go | 213 +++++----- internal/orchestrator/backup_sources_test.go | 258 ++++++++++-- internal/orchestrator/decrypt.go | 187 ++++++++- internal/orchestrator/decrypt_test.go | 373 +++++++++++++++--- internal/orchestrator/decrypt_tui.go | 2 +- .../orchestrator/decrypt_workflow_test.go | 9 +- internal/orchestrator/restore_tui.go | 14 +- 8 files changed, 836 insertions(+), 224 deletions(-) diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 329d867..915992c 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=85844 +pid=118515 host=pve -time=2026-01-16T13:24:38+01:00 +time=2026-01-16T14:16:23+01:00 diff --git a/internal/orchestrator/backup_sources.go b/internal/orchestrator/backup_sources.go index 2283d69..a4669de 100644 --- a/internal/orchestrator/backup_sources.go +++ b/internal/orchestrator/backup_sources.go @@ -30,7 +30,7 @@ func buildDecryptPathOptions(cfg *config.Config, logger *logging.Logger) (option logging.DebugStep(logger, "build backup source options", "skip (cfg=nil)") return nil } - done := logging.DebugStart(logger, "build backup source options", "secondary=%v cloud=%v", cfg.SecondaryEnabled, cfg.CloudEnabled) + 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) @@ -44,76 +44,46 @@ func buildDecryptPathOptions(cfg *config.Config, logger *logging.Logger) (option logging.DebugStep(logger, "build backup source options", "skip local (empty)") } - if cfg.SecondaryEnabled { - 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 { - logging.DebugStep(logger, "build backup source options", "skip secondary (enabled but path empty)") - } + 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 (disabled)") + 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 - timeout := 10 * time.Second - scanCtx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - prescanDone := logging.DebugStart(logger, "cloud prescan", "mode=rclone root=%s timeout=%s", cloudRoot, timeout) - candidates, err := discoverRcloneBackups(scanCtx, cloudRoot, logger) - prescanDone(err) - if err == nil && len(candidates) > 0 { - logging.DebugStep(logger, "build backup source options", "add cloud rclone (candidates=%d)", len(candidates)) - options = append(options, decryptPathOption{ - Label: "Cloud backups (rclone)", - Path: cloudRoot, - IsRclone: true, - }) - } else if err != nil { - logging.DebugStep(logger, "build backup source options", "skip cloud rclone (scan error=%v)", err) - } else { - logging.DebugStep(logger, "build backup source options", "skip cloud rclone (no candidates)") - } + 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 - prescanDone := logging.DebugStart(logger, "cloud prescan", "mode=filesystem root=%s", cloudRoot) - candidates, err := discoverBackupCandidates(logger, cloudRoot) - prescanDone(err) - if err == nil && len(candidates) > 0 { - logging.DebugStep(logger, "build backup source options", "add cloud filesystem (candidates=%d)", len(candidates)) - options = append(options, decryptPathOption{ - Label: "Cloud backups", - Path: cloudRoot, - IsRclone: false, - }) - } else if err != nil { - logging.DebugStep(logger, "build backup source options", "skip cloud filesystem (scan error=%v)", err) - } else { - logging.DebugStep(logger, "build backup source options", "skip cloud filesystem (no candidates)") - } + 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 (disabled)") + 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) }() @@ -124,7 +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 + backup indicator (-backup- or proxmox-backup-)") + 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) @@ -141,71 +111,130 @@ func discoverRcloneBackups(ctx context.Context, remotePath string, logger *loggi totalEntries := len(lines) emptyEntries := 0 - nonBundleEntries := 0 - nonBackupEntries := 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") { - nonBundleEntries++ - 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 { - nonBackupEntries++ - 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 { - manifestErrors++ - 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++ + } } + 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_bundle=%d non_backup=%d manifest_errors=%d accepted=%d", + "summary entries=%d empty=%d non_candidate=%d manifest_errors=%d accepted=%d", totalEntries, emptyEntries, - nonBundleEntries, - nonBackupEntries, + nonCandidateEntries, manifestErrors, len(candidates), ) - logDebug(logger, "Cloud (rclone): scanned %d files, found %d valid backup bundles", len(lines), 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 diff --git a/internal/orchestrator/backup_sources_test.go b/internal/orchestrator/backup_sources_test.go index 6a15b17..96ac581 100644 --- a/internal/orchestrator/backup_sources_test.go +++ b/internal/orchestrator/backup_sources_test.go @@ -123,10 +123,8 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemotePath = "pbs-backups/server1" opts := buildDecryptPathOptions(cfg, nil) - // 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)) + 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) { @@ -144,10 +145,8 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemotePath = "server1" opts := buildDecryptPathOptions(cfg, nil) - // 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)) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) @@ -158,10 +157,8 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemotePath = "server1" opts := buildDecryptPathOptions(cfg, nil) - // 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)) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) @@ -171,8 +168,8 @@ func TestBuildDecryptPathOptions_CloudVariants(t *testing.T) { cfg.CloudRemote = "gdrive:pbs-backups" opts := buildDecryptPathOptions(cfg, nil) - if len(opts) != 2 { - t.Fatalf("len(options) = %d; want 2 (local + secondary)", len(opts)) + if len(opts) != 3 { + t.Fatalf("len(options) = %d; want 3 (local + secondary + cloud)", len(opts)) } }) } @@ -188,10 +185,8 @@ func TestBuildDecryptPathOptions_FullConfigOrder(t *testing.T) { } opts := buildDecryptPathOptions(cfg, nil) - // 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)) + 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 932c43e..a1614da 100644 --- a/internal/orchestrator/decrypt.go +++ b/internal/orchestrator/decrypt.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "filippo.io/age" "github.com/tis24dev/proxsave/internal/backup" @@ -208,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 { @@ -241,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) } @@ -413,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:") @@ -553,7 +626,7 @@ func preparePlainBundle(ctx context.Context, reader *bufio.Reader, cand *decrypt staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger) case sourceRaw: logger.Info("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath)) - staged, err = copyRawArtifactsToWorkdirWithLogger(cand, workDir, logger) + staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } @@ -730,28 +803,104 @@ func extractBundleToWorkdirWithLogger(bundlePath, workDir string, logger *loggin return staged, nil } -func copyRawArtifactsToWorkdir(cand *decryptCandidate, workDir string) (staged stagedFiles, err error) { - return copyRawArtifactsToWorkdirWithLogger(cand, workDir, nil) +func copyRawArtifactsToWorkdir(ctx context.Context, cand *decryptCandidate, workDir string) (staged stagedFiles, err error) { + return copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, nil) } -func copyRawArtifactsToWorkdirWithLogger(cand *decryptCandidate, workDir string, logger *logging.Logger) (staged stagedFiles, err error) { - done := logging.DebugStart(logger, "stage raw artifacts", "archive=%s workdir=%s", cand.RawArchivePath, workDir) +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)) - 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) + if ctx == nil { + ctx = context.Background() + } + if cand == nil { + return stagedFiles{}, fmt.Errorf("candidate is nil") + } + + 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) } - metadataDest := filepath.Join(workDir, filepath.Base(cand.RawMetadataPath)) - 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 archiveBase == "" || metaBase == "" { + return stagedFiles{}, fmt.Errorf("invalid raw candidate paths") } - checksumDest := filepath.Join(workDir, filepath.Base(cand.RawChecksumPath)) - logging.DebugStep(logger, "stage raw artifacts", "copy checksum to %s", checksumDest) - if err := copyFile(restoreFS, cand.RawChecksumPath, checksumDest); err != nil { - return stagedFiles{}, fmt.Errorf("copy checksum: %w", err) + + 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, diff --git a/internal/orchestrator/decrypt_test.go b/internal/orchestrator/decrypt_test.go index 1033bbc..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{}, @@ -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 738b929..efc1977 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -716,7 +716,7 @@ func preparePlainBundleTUI(ctx context.Context, cand *decryptCandidate, version staged, err = extractBundleToWorkdirWithLogger(cand.BundlePath, workDir, logger) case sourceRaw: logger.Debug("Staging raw artifacts for %s", filepath.Base(cand.RawArchivePath)) - staged, err = copyRawArtifactsToWorkdirWithLogger(cand, workDir, logger) + staged, err = copyRawArtifactsToWorkdirWithLogger(ctx, cand, workDir, logger) default: err = fmt.Errorf("unsupported candidate source") } 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/restore_tui.go b/internal/orchestrator/restore_tui.go index a693982..f0bb2f6 100644 --- a/internal/orchestrator/restore_tui.go +++ b/internal/orchestrator/restore_tui.go @@ -438,13 +438,13 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, logger * }) return } - if len(candidates) == 0 { - message := "No backup bundles found in selected path." - 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 From 148fc7532b08283e117f6b8118da24ad6051171f Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 16:13:00 +0100 Subject: [PATCH 5/6] Improve TUI and Restore Workflow Test Coverage Add SimulationScreen-driven TUI tests and a stub hook for RunRestoreWorkflow to prevent coverage regressions and raise overall orchestrator coverage while keeping the suite stable and fast. --- internal/orchestrator/.backup.lock | 4 +- internal/orchestrator/decrypt_tui.go | 8 +- .../decrypt_tui_simulation_test.go | 37 ++++ internal/orchestrator/restore.go | 3 +- internal/orchestrator/restore_tui.go | 16 +- .../restore_tui_simulation_test.go | 148 ++++++++++++++ .../orchestrator/restore_workflow_test.go | 183 ++++++++++++++++++ internal/orchestrator/tui_hooks.go | 7 + internal/orchestrator/tui_simulation_test.go | 121 ++++++++++++ 9 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 internal/orchestrator/decrypt_tui_simulation_test.go create mode 100644 internal/orchestrator/restore_tui_simulation_test.go create mode 100644 internal/orchestrator/restore_workflow_test.go create mode 100644 internal/orchestrator/tui_hooks.go create mode 100644 internal/orchestrator/tui_simulation_test.go diff --git a/internal/orchestrator/.backup.lock b/internal/orchestrator/.backup.lock index 915992c..aa2a853 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=118515 +pid=178866 host=pve -time=2026-01-16T14:16:23+01:00 +time=2026-01-16T16:05:58+01:00 diff --git a/internal/orchestrator/decrypt_tui.go b/internal/orchestrator/decrypt_tui.go index efc1977..ef064f2 100644 --- a/internal/orchestrator/decrypt_tui.go +++ b/internal/orchestrator/decrypt_tui.go @@ -148,7 +148,7 @@ func runDecryptSelectionWizard(ctx context.Context, cfg *config.Config, logger * 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{} @@ -587,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) @@ -626,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 @@ -799,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/restore.go b/internal/orchestrator/restore.go index 980a04d..dd1f5fa 100644 --- a/internal/orchestrator/restore.go +++ b/internal/orchestrator/restore.go @@ -34,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) { @@ -56,7 +57,7 @@ func RunRestoreWorkflow(ctx context.Context, cfg *config.Config, logger *logging }() 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 } diff --git a/internal/orchestrator/restore_tui.go b/internal/orchestrator/restore_tui.go index f0bb2f6..8acbe9f 100644 --- a/internal/orchestrator/restore_tui.go +++ b/internal/orchestrator/restore_tui.go @@ -386,7 +386,7 @@ func runRestoreSelectionWizard(ctx context.Context, cfg *config.Config, logger * 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{} @@ -671,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 @@ -796,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 @@ -933,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 @@ -1054,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 @@ -1088,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 @@ -1144,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 @@ -1207,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/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) + } +} From 586978c7b3c42288b11fb3046ed74a958fd8c264 Mon Sep 17 00:00:00 2001 From: tis24dev Date: Fri, 16 Jan 2026 16:31:44 +0100 Subject: [PATCH 6/6] Increase Coverage for Input and Encryption Utilities Add unit tests for internal/input and expand encryption.go coverage by testing exported helpers, abort/error mapping, recipient file IO (including failure paths), and key wizard/recipient preparation branches. --- internal/input/input_test.go | 148 +++++++++ internal/orchestrator/.backup.lock | 4 +- .../orchestrator/encryption_exported_test.go | 285 ++++++++++++++++++ 3 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 internal/input/input_test.go create mode 100644 internal/orchestrator/encryption_exported_test.go 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 aa2a853..abf2e49 100644 --- a/internal/orchestrator/.backup.lock +++ b/internal/orchestrator/.backup.lock @@ -1,3 +1,3 @@ -pid=178866 +pid=192633 host=pve -time=2026-01-16T16:05:58+01:00 +time=2026-01-16T16:25:03+01:00 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") + } +}