diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea19076..54d85fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,12 @@ jobs: strategy: fail-fast: false matrix: + # `os` MUST be a base matrix key. If it only appears in `include`, every + # include object is applied to the single base combination and the last + # one wins, collapsing the matrix to a single job. That silently + # reduced this job to macOS only: `go test` never ran on ubuntu or + # windows, despite the advertised 3-OS matrix. + os: [ubuntu-latest, windows-latest, macos-latest] go-version: ['1.25'] # -race is expensive (~2x time, high memory). Scope it to ubuntu-latest # on every PR; the full race sweep across all platforms runs weekly in diff --git a/.github/workflows/gga.yml b/.github/workflows/gga.yml index a168841..79964b1 100644 --- a/.github/workflows/gga.yml +++ b/.github/workflows/gga.yml @@ -18,13 +18,21 @@ jobs: # Full history so GGA can detect the base branch locally # for the PR diff range (shallow checkouts have no main ref). fetch-depth: 0 + # Checkout the PR head, NOT the default pull_request merge ref. + # On a merge ref, `main...HEAD` is empty because the merge commit + # already contains main, so GGA reports "No matching files changed + # in PR" and the gate passes without reviewing anything. + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.25' - name: Install GGA run: | - git clone --depth 1 --branch v2.8.1 \ + # Keep CI in sync with the locally verified version. v2.8.1 no-ops in + # --pr-mode --diff-only on a PR head checkout (reports "No matching + # files changed in PR" even with a non-empty diff). + git clone --depth 1 --branch v2.10.1 \ https://github.com/Gentleman-Programming/gentleman-guardian-angel /tmp/gga cd /tmp/gga ./install.sh @@ -76,14 +84,30 @@ jobs: GGA_PROVIDER: "opencode:commandcode/meta/muse-spark-1.3-contributor" PR_BASE_BRANCH: "main" run: | - # The repo .gga is gitignored, so synthesize the CI equivalent: - # GGA v2.8.1 only reads PR_BASE_BRANCH from the config file, - # not from the environment. cat << 'EOF' > .gga PROVIDER="opencode:commandcode/meta/muse-spark-1.3-contributor" RULES_FILE="AGENTS.md" + FILE_PATTERNS="*.go,*.mod,*.md,*.yml,*.yaml" STRICT_MODE="true" TIMEOUT="900" PR_BASE_BRANCH="main" EOF - gga run --pr-mode --diff-only + # GGA resolves the base branch as a local ref. After checking out the + # PR head sha there is no local `main` branch, so the range resolves + # to nothing and the review no-ops. Recreate the local ref. + git branch -f main origin/main + gga run --pr-mode 2>&1 | tee gga-output.txt + + - name: Guard against a vacuous review + run: | + # A silent no-op must never look like a passing review. + if [ -z "$(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD")" ]; then + echo "::error::The PR diff against ${{ github.event.pull_request.base.ref }} is empty; the review gate did not run." + exit 1 + fi + if grep -q 'No matching files' gga-output.txt; then + echo "::error::GGA reported 'No matching files changed in PR' despite a non-empty diff. The review gate did not review anything." + cat gga-output.txt + exit 1 + fi + echo "PR diff contains $(git diff --name-only "origin/${{ github.event.pull_request.base.ref }}...HEAD" | wc -l) changed file(s); GGA reviewed the PR" diff --git a/README.md b/README.md index 610acb2..4ef3dfb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - πŸ‘€ **Machine Profiles** β€” `bak profile` commands to scope backups per machine with independent adapter, category, preset, provider, and encryption settings - πŸ–₯️ **Cross-Platform** β€” Works on Windows, macOS, and Linux with path normalization - 🎯 **Interactive Picker** β€” TUI with bubbletea for selective category backup -- ↩️ **Undo** β€” Git-backed safety net scoped to ~/.bak with `bak undo` (git revert) +- ↩️ **Undo** β€” Target-level undo with fail-closed drift protection and Git-backed rollback in ~/.bak via `bak undo` - πŸ“¦ **Export** β€” Export backups as portable tar.gz archives ## Why bak? @@ -143,8 +143,8 @@ bak diff 20260604-150405 20260605-080000 | Command | Description | |---------|-------------| | `bak backup [--preset quick\|full\|skills] [--profile ]` | Create a backup | -| `bak restore [--dry-run] [--force] ` | Restore a backup (shows diff and prompts; --force skips confirmation only) | -| `bak undo` | Revert the last operation in ~/.bak via git revert | +| `bak restore [--dry-run] [--force] ` | Restore a backup (shows diff and prompts; warns on version mismatch, fails on newer schema; --force skips confirmation only) | +| `bak undo` | Revert the last restore operation on target files and in ~/.bak with drift protection | | `bak list [--provider ]` | List local or cloud backups | | `bak pick` | Interactive TUI picker | | `bak push [id] [--provider ] [--profile ]` | Push to a cloud backend | @@ -505,10 +505,13 @@ classDiagram - βœ… **Interactive confirmation & dry-run** β€” Always preview changes before restore; interactive confirmation required unless bypassed with `--force` - βœ… **Mandatory integrity** β€” SHA-256 checksum and manifest integrity checks cannot be bypassed, even under `--force` (which only skips the confirmation prompt) +- βœ… **Target recovery & automatic rollback** β€” Before modifying target files on restore, captures affected target pre-state in private local recovery storage (`~/.bak/recovery/`) with restricted permissions (0700/0600); stops at first copy or chmod failure and attempts automatic rollback of all attempted targets - βœ… **Permission preservation (0.4.0)** β€” Manifest schema 0.4.0 records portable file permission mode bits and reapplies them on restore; legacy 0.3.0 manifests restore in degraded mode with an explicit warning -- βœ… **Git-backed safety in ~/.bak** β€” Tracks backup snapshots and metadata in `~/.bak` with instant rollback via `bak undo` (`git revert`). Note: target tool configuration directories are not auto-committed to Git during restore +- βœ… **Target-level undo & drift protection** β€” `bak undo` restores actual target configuration files to their pre-restore state using local recovery snapshots, while creating a revert commit in `~/.bak`. If any target file was modified, deleted, created, or replaced since the restore, undo fails closed before writing any files to protect subsequent user work +- βœ… **Version compatibility & schema gating** β€” Warns on `stderr` when restoring backups created by a different or unversioned/development `bak` version; fails closed before any target write or recovery preparation if the manifest schema is newer than supported (`0.4.0`), prompting the user to upgrade - βœ… **Secret exclusion** β€” Automatically detects recognized token families (GitHub `ghp_*`, `gho_*`, `ghu_*`, `ghs_*`, `ghr_*`, OpenAI `sk-*`, Anthropic `sk-ant-*`, Slack `xoxb-*`, `xoxp-*`) and generates `.env.example` templates with redacted placeholders instead of storing real secrets - βœ… **Path validation** β€” Prevents path traversal attacks by validating that all restored paths stay within the user home directory +- βœ… **Executable journey matrix proof** β€” Validated by an eight-stage real-binary journey test suite (`tests/e2e/journey_matrix_test.go`): discovery, mutation/deletion diff recovery, dry-run zero-write guarantees, apply correctness (with POSIX permission bit preservation on non-Windows platforms), checksum verification, tamper fail-closed rejection, partial failure rollback, and target undo drift protection (verified locally on Linux; Windows and macOS behaviors are validated in CI) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 2546e68..691d6dc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,13 +80,27 @@ Instead of backing up real secrets, bak generates a `.env.example` template with - **Mandatory under `--force`**: Manifest and checksum integrity verification cannot be bypassed. The `--force` flag skips interactive confirmation only, never integrity checks. - **Permission preservation (0.4.0)**: Manifest schema `0.4.0` preserves portable file permission bits (`Mode`) at backup time and reapplies them during restore. Any chmod failures are reported as restore errors. - **Degraded 0.3.0 handling**: Legacy `0.3.0` manifests lacking mode metadata are loaded and restored in degraded mode, explicitly warning the user that restored files lack original mode metadata rather than claiming exact permission restoration. +- **Version compatibility warning**: On restore, `bak` compares the backup tool version (`bak_version`) with the running tool version. Any mismatch (including unknown, development, or empty versions on either side) produces a clear warning on stderr naming the backup ID and both versions without blocking restore. +- **Schema version gating**: Manifest schema versions newer than supported (`0.4.0`) cannot be safely interpreted and fail closed with an actionable error before any target write or recovery preparation, instructing the user to upgrade `bak`. Legacy known versions (`0.3.0`) continue to restore in degraded mode. +- **Real-binary journey verification**: An eight-stage real-binary journey matrix (`tests/e2e/journey_matrix_test.go`) characterizes discovery, mutation/deletion diff recovery, dry-run zero-write guarantees, apply correctness, manifest verification, tamper fail-closed rejection, partial failure rollback, and target undo drift protection. + +### Target Recovery and Automatic Rollback + +Restore operations are protected by private local target recovery snapshots: + +- **Pre-restore target capture**: Before any target file is modified during restore, `bak` captures the affected target files (original bytes, permission mode bits, and absence status) in private local recovery storage under `~/.bak/recovery/`. +- **Fail-closed preparation**: If target inspection, recovery staging, or the pre-restore commit fails, the restore operation aborts immediately before any target file is modified. +- **Automatic rollback on failure**: If writing any target file or applying file permissions fails, the restore halts immediately and attempts rollback of every attempted target file (including partially written files). Targets that did not exist before restore are deleted; targets that previously existed are restored to their original bytes and permissions. +- **Preserved recovery evidence**: Recovery evidence is retained locally under `~/.bak/recovery/` with restricted permissions (0700/0600) for diagnostics and manual recovery. The operation returns an error reporting the original failure and rollback outcome. +- **Private local plaintext storage**: Recovery data is stored unencrypted in local private storage under `~/.bak/recovery/` with restrictive permissions. It is never pushed to cloud providers or included in cloud backup archives. +- **Best-effort limitations**: Rollback makes a best-effort attempt to revert target files across failures, but cannot promise race-proof atomicity against external concurrent processes or OS-level permission revocations during rollback. ### Git Safety Net Local backup operations are protected by Git history tracking within `~/.bak`: - **Scoped to `~/.bak`**: Git tracking is initialized within `~/.bak` to track local backup metadata and snapshots. Target configuration directories (such as `~/.config/opencode/`) are not auto-committed to Git by bak during restore operations (`GitDir` integration on target configs is unwired). -- **`bak undo`**: Reverts the last state in the `~/.bak` repository via `git revert` β€” safe, non-destructive, and history-preserving. +- **`bak undo`**: Reverts the last restore operation by restoring target configuration files to their pre-restore state using recovery point metadata, and records a `git revert` commit in the `~/.bak` repository β€” safe, non-destructive, and history-preserving. - **No force-push**: The tool never force-pushes or rewrites Git history. ### Dry-Run and Confirmation @@ -108,8 +122,10 @@ This prevents accidental overwrites. There is no silent restoration path. ## Known Limitations - **Local Git required for undo**: The `bak undo` feature requires Git to be installed and operates on the `~/.bak` repository. +- **Target undo & drift protection**: `bak undo` projects pre-restore snapshots back to target configuration files and requires an exact match with post-restore state. If any target file has drifted (content changed, deleted, permission modified, or replaced with a directory or symlink), `bak undo` refuses all changes before writing any file. Best-effort limits: target undo requires un-drifted local state and a valid applied recovery point; concurrent external modifications during undo are not race-proof. - **Token in environment**: `GITHUB_TOKEN` and other cloud provider credentials passed via environment variables are readable by any process with access to the user's environment. - **Local backups at rest**: Backups stored locally under `~/.bak/backups/` are **never** encrypted on disk. AES-256-GCM encryption applies exclusively to cloud push/pull archives when configured per profile. Users must rely on OS filesystem permissions and full-disk encryption for local backup confidentiality. +- **Platform limits on permission assertions**: File permission preservation (`0.4.0` mode bits) is verified on non-Windows platforms (Linux and macOS); Windows filesystems do not support POSIX permission bits. Local automated suite proof is executed on Linux; Windows and macOS runtime behaviors remain CI evidence. ## Dependencies diff --git a/cmd/diff_test.go b/cmd/diff_test.go index df70b76..bd1bc69 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -5,12 +5,12 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "testing" "github.com/spf13/cobra" + configtest "github.com/danielxxomg/bak-cli/internal/config/testutil" "github.com/danielxxomg/bak-cli/internal/manifest" ) @@ -121,12 +121,7 @@ func TestDiffCmd_Args(t *testing.T) { func TestRunDiff_AllCategories(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) id1, id2 := stageDiffBackups(t, tmpDir) @@ -162,12 +157,7 @@ func TestRunDiff_AllCategories(t *testing.T) { func TestRunDiff_IdenticalBackups(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) backupsDir := filepath.Join(tmpDir, ".bak", "backups") os.MkdirAll(backupsDir, 0755) @@ -211,12 +201,7 @@ func TestRunDiff_IdenticalBackups(t *testing.T) { func TestRunDiff_MissingBackup(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -231,12 +216,7 @@ func TestRunDiff_MissingBackup(t *testing.T) { func TestRunDiff_TraversalBlocked(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) // Create a valid backup so the first ID resolves successfully. backupsDir := filepath.Join(tmpDir, ".bak", "backups") @@ -263,12 +243,7 @@ func TestRunDiff_TraversalBlocked(t *testing.T) { func TestRunDiff_EmptyManifests(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) backupsDir := filepath.Join(tmpDir, ".bak", "backups") os.MkdirAll(backupsDir, 0755) diff --git a/cmd/login_test.go b/cmd/login_test.go index ad61410..8cbc6b5 100644 --- a/cmd/login_test.go +++ b/cmd/login_test.go @@ -41,13 +41,13 @@ func TestRunLoginWithDeps_ConfigLoaderError(t *testing.T) { func TestRunLoginWithDeps_NonTTYGuard(t *testing.T) { // Override isTTY to simulate non-interactive terminal. origIsTTY := isTTY + t.Cleanup(func() { isTTY = origIsTTY }) isTTY = func() bool { return false } - defer func() { isTTY = origIsTTY }() // Enable interactive mode. origInteractive := loginInteractive + t.Cleanup(func() { loginInteractive = origInteractive }) loginInteractive = true - defer func() { loginInteractive = origInteractive }() deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -94,10 +94,14 @@ func TestRunLogin_EmptyToken(t *testing.T) { if err == nil { t.Fatal("expected error from un-authorized device login, got nil") } - // Must resolve in seconds, not the server-advertised 10 minutes β€” proves no - // real network call and the test cannot hang CI. - if elapsed > 2*time.Second { - t.Errorf("login exceeded 2s (elapsed=%v); test is not isolated from the network", elapsed) + // Must resolve in seconds, well below a real device-flow expiry, so this + // proves no real network call and the test cannot hang CI. The budget is + // deliberately generous: the loop honours the server-advertised expires_in + // (1s here) but CI runners can overshoot that by whole seconds under load, + // and a tight ceiling turned a slow macOS runner into a red build. 30s is + // still ~30x the advertised expiry and nowhere near a real 15-minute flow. + if elapsed > 30*time.Second { + t.Errorf("login took %v, far longer than the 1s server-advertised expiry; test is not isolated from the network", elapsed) } if !strings.Contains(err.Error(), "timed out") && !strings.Contains(err.Error(), "token") { t.Errorf("expected a token/timeout error, got: %v", err) @@ -115,8 +119,8 @@ func TestRunLogin_NonGitHubProviders(t *testing.T) { for _, tt := range tests { t.Run(tt.provider, func(t *testing.T) { orig := loginProvider + t.Cleanup(func() { loginProvider = orig }) loginProvider = tt.provider - defer func() { loginProvider = orig }() err := runLogin(nil, nil) if err == nil { diff --git a/cmd/pick_test.go b/cmd/pick_test.go index 0d0e1e7..5c52c8b 100644 --- a/cmd/pick_test.go +++ b/cmd/pick_test.go @@ -13,8 +13,8 @@ import ( func TestRunPickWithDeps_NonTTYGuard(t *testing.T) { // Override isTTY to simulate non-interactive terminal. origIsTTY := isTTY + t.Cleanup(func() { isTTY = origIsTTY }) isTTY = func() bool { return false } - defer func() { isTTY = origIsTTY }() deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} diff --git a/cmd/profile_test.go b/cmd/profile_test.go index 5468c54..ea578bb 100644 --- a/cmd/profile_test.go +++ b/cmd/profile_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" ) @@ -54,6 +55,11 @@ func TestProfileCmd_HasSubcommands(t *testing.T) { } func TestProfileCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -87,6 +93,11 @@ func TestProfileList_NoProfiles(t *testing.T) { } func TestProfileList_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -127,6 +138,11 @@ func TestProfileShow_NotFound(t *testing.T) { } func TestProfileShow_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -154,6 +170,7 @@ func TestProfileDelete_MissingArgs(t *testing.T) { func TestProfileDelete_NotFound(t *testing.T) { deps, _, _ := setupTestDeps(t) + cmd := &cobra.Command{} err := runProfileDeleteWithDeps(cmd, []string{"nonexistent_xyz"}, deps) if err == nil { @@ -166,6 +183,11 @@ func TestProfileDelete_NotFound(t *testing.T) { } func TestProfileDelete_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -186,8 +208,8 @@ func TestProfileDelete_Help(t *testing.T) { func TestProfileCreate_MissingProvider(t *testing.T) { oldProvider := profileCreateProvider + t.Cleanup(func() { profileCreateProvider = oldProvider }) profileCreateProvider = "" - defer func() { profileCreateProvider = oldProvider }() deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -230,8 +252,24 @@ func TestProfileCreate_NoArgs_LaunchesWizard(t *testing.T) { // with --interactive and no args attempts to launch the wizard. func TestProfileCreate_NoArgs_InteractiveAttempt(t *testing.T) { oldInteractive := profileCreateInteractive + t.Cleanup(func() { profileCreateInteractive = oldInteractive }) profileCreateInteractive = true - defer func() { profileCreateInteractive = oldInteractive }() + + // The package default already forces isTTY false, but assert the seam + // explicitly: this test must never be the reason a real wizard program + // starts. The wizard TUI blocked on input on Windows CI until the package + // timed out. + origIsTTY := isTTY + t.Cleanup(func() { isTTY = origIsTTY }) + isTTY = func() bool { return false } + + origRun := runWizardProgram + t.Cleanup(func() { runWizardProgram = origRun }) + var wizardLaunched bool + runWizardProgram = func(model tea.Model) (tea.Model, error) { + wizardLaunched = true + return model, nil + } deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -239,6 +277,9 @@ func TestProfileCreate_NoArgs_InteractiveAttempt(t *testing.T) { // With --interactive and no args, the wizard is launched. Without a TTY // it will error, but it must not be the "provide a name" error. err := runProfileCreateWithDeps(cmd, []string{}, deps) + if wizardLaunched { + t.Error("non-interactive context launched the wizard program") + } if err == nil { t.Log("wizard succeeded (TTY available)") return @@ -267,6 +308,11 @@ func TestProfileCreate_TooManyArgs(t *testing.T) { } func TestProfileCreate_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -338,6 +384,11 @@ func TestProfileCreateCmd_Flags(t *testing.T) { // --- additional profile execution tests --- func TestProfileList_Execute(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -350,6 +401,11 @@ func TestProfileList_Execute(t *testing.T) { } func TestProfileShow_ExecuteNonexistent(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -362,6 +418,11 @@ func TestProfileShow_ExecuteNonexistent(t *testing.T) { } func TestProfileDelete_ExecuteNonexistent(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -374,6 +435,11 @@ func TestProfileDelete_ExecuteNonexistent(t *testing.T) { } func TestProfileCreate_RcloneProvider(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -386,6 +452,11 @@ func TestProfileCreate_RcloneProvider(t *testing.T) { } func TestProfileCreate_AdaptersAndCategories(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) diff --git a/cmd/restore.go b/cmd/restore.go index e4709f0..b4f5d12 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/danielxxomg/bak-cli/internal/actions" + "github.com/danielxxomg/bak-cli/internal/tui/screens" ) var restoreDryRun bool @@ -68,13 +69,14 @@ func runRestoreWithDeps(cmd *cobra.Command, args []string, deps cmdDeps) error { } action := &actions.RestoreAction{ - FS: &actions.OSFileSystem{}, - DryRun: restoreDryRun, - Force: restoreForce, - Verbose: verbose, - Stdin: deps.Stdin, - Stdout: deps.Stdout, - Stderr: deps.Stderr, + FS: &actions.OSFileSystem{}, + BakVersion: Version, + DryRun: restoreDryRun, + Force: restoreForce, + Verbose: verbose, + Stdin: deps.Stdin, + Stdout: deps.Stdout, + Stderr: deps.Stderr, } if err := action.ResolveBackup(backupID); err != nil { @@ -99,31 +101,45 @@ func resolveRestoreArg(args []string, deps cmdDeps) ([]string, bool, error) { } // List backups for the picker. - backups, err := listBackups() + listAction := &actions.ListBackupsAction{} + items, err := listAction.Run() if err != nil { return nil, false, fmt.Errorf("list backups: %w", err) } - if len(backups) == 0 { + if len(items) == 0 { return nil, false, fmt.Errorf("no backups found β€” create one with 'bak backup' first") } + backups := make([]screens.BackupInfo, len(items)) + for i, item := range items { + backups[i] = screens.BackupInfo{ + ID: item.ID, + Date: item.Date, + Size: item.Size, + Status: item.Status, + Cloud: item.Cloud, + } + } + // Launch interactive picker. - m := restorePickerModel{backups: backups} + m := screens.RestorePickerModel{Backups: backups} p := tea.NewProgram(m) result, runErr := p.Run() if runErr != nil { return nil, false, fmt.Errorf("picker: %w", runErr) } - model, ok := result.(restorePickerModel) + model, ok := result.(screens.RestorePickerModel) if !ok { return nil, false, fmt.Errorf("picker: unexpected model type %T", result) } selectedID := model.SelectedID() if selectedID == "" { - _, _ = fmt.Fprintln(deps.Stdout, "Restore cancelled.") + if _, err := fmt.Fprintln(deps.Stdout, "Restore cancelled."); err != nil { + return nil, false, fmt.Errorf("write output: %w", err) + } return nil, false, nil } diff --git a/cmd/restore_picker.go b/cmd/restore_picker.go deleted file mode 100644 index 50c0dee..0000000 --- a/cmd/restore_picker.go +++ /dev/null @@ -1,106 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - - "github.com/danielxxomg/bak-cli/internal/tui" - "github.com/danielxxomg/bak-cli/internal/tui/components" - "github.com/danielxxomg/bak-cli/internal/tui/styles" -) - -// restorePickerModel is the bubbletea model for the interactive restore picker. -// It lists available backups and lets the user select one with ↑/↓ + Enter. -// q/Esc cancels. Mirrors the pickModel pattern in cmd/pick.go. -type restorePickerModel struct { - backups []tui.BackupInfo - cursor int - quitting bool - confirmed bool - width int - height int -} - -func (m restorePickerModel) Init() tea.Cmd { - return nil -} - -func (m restorePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.KeyPressMsg: - switch msg.String() { - case "q", "esc", "ctrl+c": - m.quitting = true - return m, tea.Quit - - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - - case "down", "j": - if m.cursor < len(m.backups)-1 { - m.cursor++ - } - - case keyEnter: - if len(m.backups) > 0 { - m.confirmed = true - return m, tea.Quit - } - } - } - - return m, nil -} - -func (m restorePickerModel) View() tea.View { - if m.quitting { - return tea.NewView("") - } - - // Guard against terminals below the minimum usable size. - if m.width > 0 && m.height > 0 && (m.width < 20 || m.height < 10) { - return tea.NewView(styles.HelpStyle.Render("Terminal too small (min 20x10)")) - } - - var b strings.Builder - - b.WriteString(styles.TitleStyle.Render("Select backup to restore")) - b.WriteString("\n\n") - - if len(m.backups) == 0 { - b.WriteString(styles.HelpStyle.Render("No backups found.")) - b.WriteString("\n\n") - } else { - for i, bk := range m.backups { - label := fmt.Sprintf("%-18s %-22s %s", bk.ID, bk.Date, bk.Size) - b.WriteString(components.RenderRadio(label, i == m.cursor, i == m.cursor)) - b.WriteByte('\n') - } - } - - b.WriteString("\n") - b.WriteString(components.RenderHelp([]components.HelpKey{ - {Key: "\u2191/\u2193", Desc: "navigate"}, - {Key: keyEnter, Desc: "select"}, - {Key: "q/esc", Desc: "cancel"}, - })) - - return tea.NewView(b.String()) -} - -// SelectedID returns the selected backup ID, or empty string if none. -func (m restorePickerModel) SelectedID() string { - if !m.confirmed || len(m.backups) == 0 || m.cursor >= len(m.backups) { - return "" - } - return m.backups[m.cursor].ID -} diff --git a/cmd/restore_picker_test.go b/cmd/restore_picker_test.go deleted file mode 100644 index adebff5..0000000 --- a/cmd/restore_picker_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package cmd - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/danielxxomg/bak-cli/internal/tui" -) - -// TestRestorePicker_SelectsBackup verifies Enter sets SelectedID. -func TestRestorePicker_SelectsBackup(t *testing.T) { - backups := []tui.BackupInfo{ - {ID: "20260617-120000", Date: "2026-06-17", Size: "1.0 MB"}, - {ID: "20260618-090000", Date: "2026-06-18", Size: "2.5 MB"}, - } - m := restorePickerModel{backups: backups, cursor: 1} - - newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - m = newM.(restorePickerModel) - - if !m.confirmed { - t.Error("Enter should set confirmed=true") - } - if m.SelectedID() != "20260618-090000" { - t.Errorf("SelectedID = %q, want %q", m.SelectedID(), "20260618-090000") - } -} - -// TestRestorePicker_EmptyList verifies empty backups list handled. -func TestRestorePicker_EmptyList(t *testing.T) { - m := restorePickerModel{backups: nil} - - view := m.View() - if !strings.Contains(view.Content, "No backups") { - t.Error("empty list should show 'No backups' message") - } - - newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - m = newM.(restorePickerModel) - if m.confirmed { - t.Error("Enter on empty list should not confirm") - } -} - -// TestRestorePicker_Cancel verifies q/Esc cancels. -func TestRestorePicker_Cancel(t *testing.T) { - backups := []tui.BackupInfo{ - {ID: "20260617-120000", Date: "2026-06-17", Size: "1.0 MB"}, - } - m := restorePickerModel{backups: backups, cursor: 0} - newM, _ := m.Update(tea.KeyPressMsg{Code: 'q'}) - m = newM.(restorePickerModel) - - if m.confirmed { - t.Error("q should not confirm selection") - } - if m.SelectedID() != "" { - t.Errorf("SelectedID after q = %q, want empty", m.SelectedID()) - } -} - -// TestRestorePicker_CursorBounds verifies cursor cannot go out of bounds. -func TestRestorePicker_CursorBounds(t *testing.T) { - backups := []tui.BackupInfo{ - {ID: "a", Date: "d1", Size: "1"}, - {ID: "b", Date: "d2", Size: "2"}, - } - - m := restorePickerModel{backups: backups, cursor: 0} - newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) - m = newM.(restorePickerModel) - if m.cursor != 0 { - t.Errorf("cursor should stay 0, got %d", m.cursor) - } - - m2 := restorePickerModel{backups: backups, cursor: 1} - newM2, _ := m2.Update(tea.KeyPressMsg{Code: tea.KeyDown}) - m2 = newM2.(restorePickerModel) - if m2.cursor != 1 { - t.Errorf("cursor should stay 1, got %d", m2.cursor) - } -} - -// TestRestorePicker_NarrowTerminal shows "too small" message. -func TestRestorePicker_NarrowTerminal(t *testing.T) { - m := restorePickerModel{width: 19, height: 9} - view := m.View() - - if !strings.Contains(view.Content, "too small") { - t.Error("narrow terminal should show 'too small' message") - } -} diff --git a/cmd/restore_test.go b/cmd/restore_test.go index d35dbc0..641590b 100644 --- a/cmd/restore_test.go +++ b/cmd/restore_test.go @@ -71,6 +71,11 @@ func TestRestoreCmd_Args(t *testing.T) { } func TestRestoreCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -93,25 +98,30 @@ func TestRestoreCmd_Help(t *testing.T) { } } -func TestRestoreCmd_Use(t *testing.T) { +func TestRestoreCmd_UseAndDescription(t *testing.T) { cmd := findSubcommand(t, "restore") if cmd == nil { t.Fatal("restore command not found") } - // Use should start with "restore". - if cmd.Use == "" { - t.Fatal("restore command should have a Use string") - } - if !strings.HasPrefix(cmd.Use, "restore") { - t.Fatalf("Use = %q, should start with \"restore\"", cmd.Use) - } - - if cmd.Short == "" { - t.Fatal("restore command should have a short description") + tests := []struct { + name string + got string + wantPrefix string + }{ + {name: "Use", got: cmd.Use, wantPrefix: "restore"}, + {name: "Short", got: cmd.Short}, + {name: "Long", got: cmd.Long}, } - if cmd.Long == "" { - t.Fatal("restore command should have a long description") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got == "" { + t.Fatalf("restore %s should not be empty", tt.name) + } + if tt.wantPrefix != "" && !strings.HasPrefix(tt.got, tt.wantPrefix) { + t.Fatalf("restore %s = %q, should start with %q", tt.name, tt.got, tt.wantPrefix) + } + }) } } @@ -153,6 +163,11 @@ func TestRunRestoreWithDeps_BackupNotFound(t *testing.T) { } func TestRunRestore_MissingArgs(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) configtest.SetConfigHome(t, t.TempDir()) // Reset rootCmd and restoreCmd state to avoid help-flag leakage // from previous tests (e.g., TestRestoreCmd_Help). pflag doesn't @@ -197,6 +212,11 @@ func TestRunRestore_MissingArgs(t *testing.T) { // does not leak state into subsequent Execute() calls on the shared // restoreCmd. This tests the isolation fix for TestRunRestore_MissingArgs. func TestRestoreHelpFollowedByExecute(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) // Step 1: Run --help on restore (like TestRestoreCmd_Help does). buf1 := new(bytes.Buffer) rootCmd.SetOut(buf1) @@ -245,80 +265,51 @@ func TestRestoreHelpFollowedByExecute(t *testing.T) { } } -func TestRunRestore_BackupNotFound(t *testing.T) { - configtest.SetConfigHome(t, t.TempDir()) - bufOut := new(bytes.Buffer) - bufErr := new(bytes.Buffer) - rootCmd.SetOut(bufOut) - rootCmd.SetErr(bufErr) - - rootCmd.SetArgs([]string{"restore", "20250101-000000"}) - err := rootCmd.Execute() - - if err == nil { - t.Log("restore of non-existent backup succeeded (backup may exist)") - return - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found', got: %v", err) - } -} - -func TestRunRestore_DryRunNonexistent(t *testing.T) { - configtest.SetConfigHome(t, t.TempDir()) - bufOut := new(bytes.Buffer) - bufErr := new(bytes.Buffer) - rootCmd.SetOut(bufOut) - rootCmd.SetErr(bufErr) - - rootCmd.SetArgs([]string{"restore", "--dry-run", "20250101-000000"}) - err := rootCmd.Execute() - - if err == nil { - t.Log("restore --dry-run succeeded (backup may exist)") - return - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found', got: %v", err) - } -} - -func TestRestoreCmd_UseAndDescription(t *testing.T) { - cmd := findSubcommand(t, "restore") - if cmd == nil { - t.Fatal("restore command not found") - } - if cmd.Use == "" { - t.Fatal("restore Use should not be empty") - } - if cmd.Short == "" { - t.Fatal("restore Short should not be empty") - } - if cmd.Long == "" { - t.Fatal("restore Long should not be empty") +func TestRunRestore_FlagVariants(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "no_flags", args: []string{"restore", "20250101-000000"}}, + {name: "dry_run", args: []string{"restore", "--dry-run", "20250101-000000"}}, + {name: "force", args: []string{"restore", "--force", "20250101-000000"}}, + {name: "override", args: []string{"restore", "--override", "20250101-000000"}}, + {name: "override_and_dry_run", args: []string{"restore", "--override", "--dry-run", "20250101-000000"}}, } -} - -func TestRunRestore_ForceFlag(t *testing.T) { - configtest.SetConfigHome(t, t.TempDir()) - bufOut := new(bytes.Buffer) - bufErr := new(bytes.Buffer) - rootCmd.SetOut(bufOut) - rootCmd.SetErr(bufErr) - rootCmd.SetArgs([]string{"restore", "--force", "20250101-000000"}) - err := rootCmd.Execute() - - if err == nil { - t.Log("restore --force succeeded (backup may exist)") - return - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found', got: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + configtest.SetConfigHome(t, t.TempDir()) + bufOut := new(bytes.Buffer) + bufErr := new(bytes.Buffer) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufErr) + + rootCmd.SetArgs(tt.args) + err := rootCmd.Execute() + + if err == nil { + t.Logf("restore %v succeeded (backup may exist)", tt.args) + return + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } + }) } } func TestRunRestore_VerboseFlagExists(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) // --verbose exists as a cobra PersistentFlag registered in root.go. // Verify it is available as a global/persistent flag concept. // The flag is registered in Execute() at runtime; in tests we check @@ -338,44 +329,6 @@ func TestRunRestore_VerboseFlagExists(t *testing.T) { } } -func TestRunRestore_OverrideFlag(t *testing.T) { - configtest.SetConfigHome(t, t.TempDir()) - bufOut := new(bytes.Buffer) - bufErr := new(bytes.Buffer) - rootCmd.SetOut(bufOut) - rootCmd.SetErr(bufErr) - - rootCmd.SetArgs([]string{"restore", "--override", "20250101-000000"}) - err := rootCmd.Execute() - - if err == nil { - t.Log("restore --override succeeded (backup may exist)") - return - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found', got: %v", err) - } -} - -func TestRunRestore_OverrideAndDryRun(t *testing.T) { - configtest.SetConfigHome(t, t.TempDir()) - bufOut := new(bytes.Buffer) - bufErr := new(bytes.Buffer) - rootCmd.SetOut(bufOut) - rootCmd.SetErr(bufErr) - - rootCmd.SetArgs([]string{"restore", "--override", "--dry-run", "20250101-000000"}) - err := rootCmd.Execute() - - if err == nil { - t.Log("restore --override --dry-run succeeded (backup may exist)") - return - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should mention 'not found', got: %v", err) - } -} - func TestTuiRunRestore_IntegrityValidation(t *testing.T) { tests := []struct { name string @@ -522,12 +475,14 @@ func TestRunRestoreWithDeps_TamperedManifestWithForceFails(t *testing.T) { } // Set --force flag. + origForce := restoreForce + origDryRun := restoreDryRun + t.Cleanup(func() { + restoreForce = origForce + restoreDryRun = origDryRun + }) restoreForce = true restoreDryRun = false - defer func() { - restoreForce = false - restoreDryRun = false - }() err := runRestoreWithDeps(cmd, []string{backupID}, deps) if err == nil { @@ -542,3 +497,255 @@ func TestRunRestoreWithDeps_TamperedManifestWithForceFails(t *testing.T) { t.Errorf("target file should not exist after failed validation") } } + +// TestRunRestoreWithDeps_RecoveryPointCreatedOnApply proves that the CLI/shared-action path +// creates a recovery point under ~/.bak/recovery/ upon successful restore apply (does not claim +// an independently executed TUI flow). +func TestRunRestoreWithDeps_RecoveryPointCreatedOnApply(t *testing.T) { + home := t.TempDir() + configtest.SetConfigHome(t, home) + + backupID := "20260101-123456" + bakDir := filepath.Join(home, ".bak") + backupDir := filepath.Join(bakDir, "backups", backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + content := []byte("setting=on\n") + if err := os.WriteFile(filepath.Join(adapterDir, "config.json"), content, 0644); err != nil { + t.Fatal(err) + } + + h := sha256.Sum256(content) + m := manifest.New(backupID, runtime.GOOS, "testhost", "test", "quick", []string{"config"}) + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/config.json", + BackupPath: "test-adapter/config.json", + Hash: fmt.Sprintf("sha256:%x", h), + Size: int64(len(content)), + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + deps, _, _ := setupTestDeps(t) + cmd := findSubcommand(t, "restore") + if cmd == nil { + t.Fatal("restore command not found") + } + + origForce := restoreForce + origDryRun := restoreDryRun + t.Cleanup(func() { + restoreForce = origForce + restoreDryRun = origDryRun + }) + restoreForce = true + restoreDryRun = false + + err := runRestoreWithDeps(cmd, []string{backupID}, deps) + if err != nil { + t.Fatalf("runRestoreWithDeps failed: %v", err) + } + + targetPath := filepath.Join(home, ".config", "bak", "config.json") + if _, statErr := os.Stat(targetPath); statErr != nil { + t.Fatalf("target file should exist after restore: %v", statErr) + } + + recDir := filepath.Join(home, ".bak", "recovery") + entries, err := os.ReadDir(recDir) + if err != nil || len(entries) == 0 { + t.Fatalf("expected recovery point directory in %s, got err: %v, count: %d", recDir, err, len(entries)) + } +} + +func TestRunRestoreWithDeps_BakVersionMismatchWarning(t *testing.T) { + home := t.TempDir() + configtest.SetConfigHome(t, home) + + backupID := "20260101-120000" + backupDir := filepath.Join(home, ".bak", "backups", backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + content := []byte("key=value\n") + backedFile := filepath.Join(adapterDir, "config.json") + if err := os.WriteFile(backedFile, content, 0644); err != nil { + t.Fatal(err) + } + + h := sha256.Sum256(content) + m := manifest.New(backupID, "linux", "host", "1.0.0", "quick", []string{"config"}) + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/config.json", + BackupPath: "test-adapter/config.json", + Hash: fmt.Sprintf("sha256:%x", h), + Size: int64(len(content)), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + Version = "2.0.0" + + origForce := restoreForce + origDryRun := restoreDryRun + t.Cleanup(func() { + restoreForce = origForce + restoreDryRun = origDryRun + }) + restoreForce = true + restoreDryRun = false + + var stdoutBuf, stderrBuf bytes.Buffer + deps := cmdDeps{ + ConfigLoader: defaultDeps.ConfigLoader, + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + Stdin: strings.NewReader(""), + } + + cmd := findSubcommand(t, "restore") + err := runRestoreWithDeps(cmd, []string{backupID}, deps) + if err != nil { + t.Fatalf("runRestoreWithDeps failed: %v", err) + } + + stderrOut := stderrBuf.String() + if !strings.Contains(stderrOut, "warning:") { + t.Fatalf("expected warning on Stderr, got: %q", stderrOut) + } + if !strings.Contains(stderrOut, backupID) || !strings.Contains(stderrOut, "1.0.0") || !strings.Contains(stderrOut, "2.0.0") { + t.Errorf("warning %q should mention backup ID, 1.0.0, and 2.0.0", stderrOut) + } +} + +func TestRunRestoreWithDeps_NewerSchemaFailsClosed(t *testing.T) { + home := t.TempDir() + configtest.SetConfigHome(t, home) + + backupID := "20260101-120000" + backupDir := filepath.Join(home, ".bak", "backups", backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + content := []byte("key=value\n") + backedFile := filepath.Join(adapterDir, "config.json") + if err := os.WriteFile(backedFile, content, 0644); err != nil { + t.Fatal(err) + } + + h := sha256.Sum256(content) + m := manifest.New(backupID, "linux", "host", "1.0.0", "quick", []string{"config"}) + m.Version = "0.5.0" // newer than 0.4.0 + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/config.json", + BackupPath: "test-adapter/config.json", + Hash: fmt.Sprintf("sha256:%x", h), + Size: int64(len(content)), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + origForce := restoreForce + origDryRun := restoreDryRun + t.Cleanup(func() { + restoreForce = origForce + restoreDryRun = origDryRun + }) + restoreForce = true + restoreDryRun = false + + var stdoutBuf, stderrBuf bytes.Buffer + deps := cmdDeps{ + ConfigLoader: defaultDeps.ConfigLoader, + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + Stdin: strings.NewReader(""), + } + + cmd := findSubcommand(t, "restore") + err := runRestoreWithDeps(cmd, []string{backupID}, deps) + if err == nil { + t.Fatal("expected error for newer schema, got nil") + } + if !strings.Contains(err.Error(), "unsupported manifest schema version") { + t.Errorf("error %q should mention unsupported manifest schema version", err.Error()) + } +} + +func TestTuiRunRestore_BakVersionAndSchema(t *testing.T) { + home := t.TempDir() + configtest.SetConfigHome(t, home) + + backupID := "20260101-120000" + backupDir := filepath.Join(home, ".bak", "backups", backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + content := []byte("key=value\n") + backedFile := filepath.Join(adapterDir, "config.json") + if err := os.WriteFile(backedFile, content, 0644); err != nil { + t.Fatal(err) + } + + h := sha256.Sum256(content) + m := manifest.New(backupID, "linux", "host", "0.9.0", "quick", []string{"config"}) + m.Version = "0.5.0" + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/config.json", + BackupPath: "test-adapter/config.json", + Hash: fmt.Sprintf("sha256:%x", h), + Size: int64(len(content)), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + out, err := tuiRunRestore(backupID, false) + if err == nil { + t.Fatalf("expected error for newer schema, got out: %s", out) + } + if !strings.Contains(err.Error(), "unsupported manifest schema version") { + t.Errorf("expected error to mention unsupported schema version, got: %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 14b3316..d391e0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "path/filepath" "github.com/spf13/cobra" @@ -15,7 +14,6 @@ import ( "github.com/danielxxomg/bak-cli/internal/adapters/register" "github.com/danielxxomg/bak-cli/internal/backup" "github.com/danielxxomg/bak-cli/internal/config" - "github.com/danielxxomg/bak-cli/internal/manifest" "github.com/danielxxomg/bak-cli/internal/tui" "github.com/danielxxomg/bak-cli/internal/tui/screens" ) @@ -119,58 +117,29 @@ func formatSize(size int64) string { // listBackups scans the local backups directory and returns a slice of // BackupInfo suitable for populating the TUI dashboard. func listBackups() ([]tui.BackupInfo, error) { - bakDir, err := backup.BakDir() - if err != nil { - return nil, fmt.Errorf("bak dir: %w", err) - } - return listBackupsFrom(bakDir) + return listBackupsFrom("") } // listBackupsFrom scans the given bakDir for backup directories, loads // their manifests, and returns a slice of BackupInfo. This function is // package-visible for testability via cmd's internal test files. func listBackupsFrom(bakDir string) ([]tui.BackupInfo, error) { - backupsDir := filepath.Join(bakDir, "backups") - entries, err := os.ReadDir(backupsDir) + action := &actions.ListBackupsAction{ + BakDir: bakDir, + } + items, err := action.Run() if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("read backups dir: %w", err) + return nil, err } - - var result []tui.BackupInfo - for _, entry := range entries { - if !entry.IsDir() { - continue + result := make([]tui.BackupInfo, len(items)) + for i, item := range items { + result[i] = tui.BackupInfo{ + ID: item.ID, + Date: item.Date, + Size: item.Size, + Status: item.Status, + Cloud: item.Cloud, } - m, err := manifest.Load(filepath.Join(backupsDir, entry.Name())) - if err != nil { - continue // skip corrupt/incomplete backup dirs - } - - // Format date from backup ID (YYYYMMDD-HHMMSS). - date := "" - if len(m.ID) >= 15 { - date = fmt.Sprintf("%s-%s-%s %s:%s:%s", - m.ID[0:4], m.ID[4:6], m.ID[6:8], - m.ID[9:11], m.ID[11:13], m.ID[13:15]) - } - - // Pick first adapter name as cloud provider hint. - cloudStr := "none" - for name := range m.Adapters { - cloudStr = name - break - } - - result = append(result, tui.BackupInfo{ - ID: m.ID, - Date: date, - Size: formatSize(m.TotalSize), - Status: "ok", - Cloud: cloudStr, - }) } return result, nil } @@ -242,12 +211,13 @@ func tuiRunRestore(backupID string, dryRun bool) (string, error) { var buf bytes.Buffer action := &actions.RestoreAction{ - FS: &actions.OSFileSystem{}, - DryRun: dryRun, - Force: !dryRun, // TUI modal is the confirmation gate - Verbose: verbose, - Stdout: &buf, - Stderr: &buf, + FS: &actions.OSFileSystem{}, + BakVersion: Version, + DryRun: dryRun, + Force: !dryRun, // TUI modal is the confirmation gate + Verbose: verbose, + Stdout: &buf, + Stderr: &buf, } if err := action.ResolveBackup(backupID); err != nil { @@ -339,6 +309,13 @@ func tuiSetActiveProfile(name string) error { // tuiRunWizard launches the interactive profile creation wizard. // Returns a ProfileInfo with the created profile data. func tuiRunWizard() (tui.ProfileInfo, error) { + // Gate on a real terminal before starting the program. Every other + // interactive entry point (launchWizard, pick, the restore picker, login) + // does this; without it a non-interactive invocation blocks on input + // instead of failing fast. + if !isTTY() { + return tui.ProfileInfo{}, fmt.Errorf("interactive wizard requires a terminal (TTY)") + } m := screens.NewWizardModel("profile-create", nil) // nil providers β†’ wizard auto-detects p := tea.NewProgram(m) finalModel, err := p.Run() diff --git a/cmd/root_test.go b/cmd/root_test.go index 7b71acc..35b8bb2 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/danielxxomg/bak-cli/internal/actions" + configtest "github.com/danielxxomg/bak-cli/internal/config/testutil" "github.com/danielxxomg/bak-cli/internal/restore" + "github.com/danielxxomg/bak-cli/internal/tui" ) // --- Root command structure tests --- @@ -51,6 +53,11 @@ func TestRootCmd_HasSubcommands(t *testing.T) { } func TestRootCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -112,6 +119,11 @@ func TestBackupCmd_Flags(t *testing.T) { } func TestBackupCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -156,6 +168,11 @@ func TestVersionCmd_Structure(t *testing.T) { } func TestVersionCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) @@ -415,6 +432,11 @@ func TestVersionIsNonEmpty(t *testing.T) { // --- Execute tests --- func TestExecute_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) // Test root command --help execution. bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) @@ -433,29 +455,62 @@ func TestExecute_Help(t *testing.T) { } func TestExecute_NoSubcommand(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufErr) + // Force the non-interactive path. Without this the test consults the real + // os.Stdin: on Windows CI runners isatty can report a terminal, so root's + // RunE launched the real Bubble Tea program and the job hung until the + // 10-minute timeout. Never let a unit test reach a live TUI. + origIsTTY := isTTY + isTTY = func() bool { return false } + t.Cleanup(func() { isTTY = origIsTTY }) + + origRunTUI := runTUI + var tuiLaunched bool + runTUI = func(tui.Deps) error { + tuiLaunched = true + return nil + } + t.Cleanup(func() { runTUI = origRunTUI }) + // Running root without a subcommand or help flag should show help. rootCmd.SetArgs([]string{}) err := rootCmd.Execute() if err != nil { t.Fatalf("root execution without subcommand should not error: %v", err) } + if tuiLaunched { + t.Error("root without a subcommand launched the TUI in a non-interactive context") + } } func TestExecute_VerboseFlag(t *testing.T) { // The --verbose flag is added in the Execute() function (root.go). // Test that it works after execution. + origVerbose := verbose + t.Cleanup(func() { + verbose = origVerbose + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) rootCmd.SetOut(bufOut) rootCmd.SetErr(bufErr) // Add the persistent verbose flag like Execute() does. - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + if rootCmd.PersistentFlags().Lookup("verbose") == nil { + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + } rootCmd.SetArgs([]string{"--help"}) err := rootCmd.Execute() @@ -469,6 +524,11 @@ func TestExecute_VerboseFlag(t *testing.T) { } func TestExecute_UnknownCommand(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) rootCmd.SetOut(bufOut) @@ -669,7 +729,7 @@ func mustWriteFile(t *testing.T, path, content string) { // RestoreAction instead of returning hardcoded strings. func TestTuiRunRestore_RealAction(t *testing.T) { home := t.TempDir() - t.Setenv("HOME", home) + configtest.SetConfigHome(t, home) bakDir := filepath.Join(home, ".bak") // Create a backup directory with a valid manifest. @@ -709,24 +769,20 @@ func TestTuiRunRestore_RealAction(t *testing.T) { } } -// TestTuiRunWizard_RealWizard verifies that tuiRunWizard launches the real -// wizardModel instead of returning a hardcoded ProfileInfo. +// TestTuiRunWizard_RealWizard verifies that tuiRunWizard is wired to the real +// wizard instead of returning a hardcoded ProfileInfo, and that it refuses to +// start a program without a terminal. func TestTuiRunWizard_RealWizard(t *testing.T) { - // tuiRunWizard launches tea.NewProgram(wizardModel) and returns user-selected values. - // In a non-TTY test, tea.NewProgram won't work β€” but we can verify the function - // no longer returns the hardcoded "default" profile by checking it returns a - // real implementation (the function should try to launch the wizard). - // - // The function will fail in non-TTY tests, but this proves the stub is gone. + // Force non-interactive explicitly instead of relying on the host probe. + origIsTTY := isTTY + isTTY = func() bool { return false } + t.Cleanup(func() { isTTY = origIsTTY }) + _, err := tuiRunWizard() - // In test env without TTY, we expect either a TTY error or the real result. - // Either way, the hardcoded stub is removed β€” the function now calls real code. - if err != nil { - // Real wizard launch failed (expected in non-TTY) β€” stub is gone. - // Error from real code is different from the old silent success. - return + if err == nil { + t.Fatal("tuiRunWizard without a TTY must fail instead of starting a program") + } + if !strings.Contains(err.Error(), "TTY") { + t.Errorf("expected a TTY error, got: %v", err) } - // If it succeeds (TTY env), verify the result isn't a hardcoded stub. - // (Rare in test, but worth checking.) - t.Log("tuiRunWizard succeeded in test: real return value") } diff --git a/cmd/testhelper_test.go b/cmd/testhelper_test.go index 66a80e2..723eb7b 100644 --- a/cmd/testhelper_test.go +++ b/cmd/testhelper_test.go @@ -7,6 +7,19 @@ import ( "github.com/danielxxomg/bak-cli/internal/config" ) +// Force the whole cmd test package into non-interactive mode. +// +// Without this, any test that reaches an unguarded tea.NewProgram call site +// depends on the host terminal probe. On Linux and macOS CI runners stdin is +// piped, so isTTY() is false and nothing happens. On Windows runners the probe +// can report a terminal, so the same test renders a real TUI and blocks on +// input until the package hits the 10-minute test timeout β€” which silently +// skipped most of the CI matrix. Tests that specifically exercise an +// interactive path override isTTY back to true. +func init() { + isTTY = func() bool { return false } +} + // setupTestDeps returns injectable cmdDeps with mock config and buffer I/O. // Tests call runXWithDeps directly with these deps to isolate from real config. func setupTestDeps(t *testing.T) (cmdDeps, *bytes.Buffer, *bytes.Buffer) { diff --git a/cmd/tty_test.go b/cmd/tty_test.go index 8790e31..a06af86 100644 --- a/cmd/tty_test.go +++ b/cmd/tty_test.go @@ -11,13 +11,24 @@ import ( // TestIsTTY β€” RED (tty.go does not exist yet) // ============================================================================= -// TestIsTTY_ReturnsFalseInTestEnv verifies the real isTTY (which reads -// os.Stdin) returns false in piped test environments. We also verify the -// injection point works by overriding it. +// TestIsTTY_ReturnsFalseInTestEnv verifies the cmd test package runs +// non-interactive by default, regardless of the host terminal. +// +// This used to call the real probe and assert false, which held on Linux and +// macOS CI (stdin piped) but not on Windows, where the probe can report a +// terminal and a unit test would launch a real TUI. The invariant the package +// now relies on is the forced default, not the host. func TestIsTTY_ReturnsFalseInTestEnv(t *testing.T) { - // The real isTTY should return false because test runners pipe stdin. if isTTY() { - t.Error("isTTY() = true in test environment, want false (stdin is piped)") + t.Error("isTTY() = true in test environment, want false (package default is non-interactive)") + } +} + +// TestRealIsTTY_IsTheDefaultProbe verifies the injection variable is wired to +// the real terminal probe in production code. +func TestRealIsTTY_IsTheDefaultProbe(t *testing.T) { + if realIsTTY == nil { + t.Fatal("realIsTTY must not be nil") } } @@ -25,7 +36,7 @@ func TestIsTTY_ReturnsFalseInTestEnv(t *testing.T) { // overridden for testing, following the AGENTS.md pattern. func TestIsTTY_OverridePointWorks(t *testing.T) { orig := isTTY - defer func() { isTTY = orig }() + t.Cleanup(func() { isTTY = orig }) // Override to return true. isTTY = func() bool { return true } @@ -47,7 +58,7 @@ func TestIsTTY_OverridePointWorks(t *testing.T) { func TestRunTUI_InjectionPoint(t *testing.T) { // Save the original and restore after the test. orig := runTUI - defer func() { runTUI = orig }() + t.Cleanup(func() { runTUI = orig }) called := false var receivedDeps tui.Deps @@ -79,7 +90,7 @@ func TestRunTUI_InjectionPoint(t *testing.T) { // TestRunTUI_PropagatesError verifies errors from runTUI are propagated. func TestRunTUI_PropagatesError(t *testing.T) { orig := runTUI - defer func() { runTUI = orig }() + t.Cleanup(func() { runTUI = orig }) wantErr := errors.New("TUI failed") runTUI = func(deps tui.Deps) error { diff --git a/cmd/undo.go b/cmd/undo.go index 2409a82..4917155 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -11,12 +11,16 @@ import ( var undoCmd = &cobra.Command{ Use: "undo", Short: "Revert the last bak operation", - Long: `Reverts the last operation (restore or backup) by creating a new -revert commit. This is equivalent to 'git revert HEAD' on the bak + Long: `Reverts the last restore operation by restoring target configuration +files to their pre-restore state and creating a revert commit in the bak storage directory (~/.bak/). -The undo is safe and non-destructive β€” it does NOT rewrite history -or force-push. You can undo the undo by running 'bak undo' again. +Undo includes fail-closed drift protection: if any target file was modified, +deleted, created, or replaced since the restore was applied, undo refuses all +changes before writing any file. + +The undo is safe, non-destructive, and history-preserving β€” it does NOT rewrite +history or force-push. Examples: bak undo Revert the last restore @@ -35,6 +39,7 @@ func runUndo(cmd *cobra.Command, args []string) error { func runUndoWithDeps(cmd *cobra.Command, args []string, deps cmdDeps) error { action := &actions.UndoAction{ + FS: &actions.OSFileSystem{}, Stdout: deps.Stdout, IsRepo: gitutil.IsRepo, UndoFn: func(repoPath string) error { diff --git a/cmd/undo_test.go b/cmd/undo_test.go index 27cde72..d6b0bb2 100644 --- a/cmd/undo_test.go +++ b/cmd/undo_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/spf13/cobra" + + configtest "github.com/danielxxomg/bak-cli/internal/config/testutil" ) func TestUndoCmd_Structure(t *testing.T) { @@ -60,20 +62,34 @@ func TestUndoCmd_Args(t *testing.T) { } func TestUndoCmd_Help(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) buf := new(bytes.Buffer) rootCmd.SetOut(buf) rootCmd.SetErr(buf) rootCmd.SetArgs([]string{"undo", "--help"}) - rootCmd.Execute() + if err := rootCmd.Execute(); err != nil { + t.Fatalf("undo --help should not error: %v", err) + } output := buf.String() if !strings.Contains(output, "undo") { t.Fatal("help output should mention 'undo'") } - if !strings.Contains(output, "revert") || !strings.Contains(output, "Revert") { + if !strings.Contains(output, "revert") && !strings.Contains(output, "Revert") { t.Fatal("help output should mention revert") } + + cmd := findSubcommand(t, "undo") + if cmd != nil { + if err := cmd.Flags().Set("help", "false"); err != nil { + t.Fatal(err) + } + } } func TestUndoCmd_Use(t *testing.T) { @@ -100,11 +116,18 @@ func TestUndoCmd_Use(t *testing.T) { if cmd.Long == "" { t.Fatal("undo command should have a long description") } + if !strings.Contains(cmd.Long, "target configuration") { + t.Errorf("expected Long description to mention target configuration, got: %s", cmd.Long) + } + if !strings.Contains(cmd.Long, "drift") { + t.Errorf("expected Long description to mention drift, got: %s", cmd.Long) + } } // --- runUndo execution tests --- func TestRunUndoWithDeps_Delegation(t *testing.T) { + configtest.SetConfigHome(t, t.TempDir()) // Verify runUndoWithDeps creates UndoAction and delegates without panic. deps, _, _ := setupTestDeps(t) @@ -114,11 +137,9 @@ func TestRunUndoWithDeps_Delegation(t *testing.T) { } err := runUndoWithDeps(cmd, nil, deps) - // Undo requires a git repo in ~/.bak. If none exists, it errors. - // The key assertion: the wrapper successfully delegated β€” no nil panic. + // In an isolated home without .bak repo, undo must return a repository error. if err == nil { - t.Log("undo succeeded β€” .bak git repo exists") - return + t.Fatal("expected error from undo delegation in empty isolated home, got nil") } errStr := err.Error() if !strings.Contains(errStr, "repository") && @@ -131,6 +152,19 @@ func TestRunUndoWithDeps_Delegation(t *testing.T) { } func TestRunUndo_NoBakRepo(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + configtest.SetConfigHome(t, t.TempDir()) + cmd := findSubcommand(t, "undo") + if cmd != nil { + cmd.InitDefaultHelpFlag() + if err := cmd.Flags().Set("help", "false"); err != nil { + t.Fatal(err) + } + } bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) rootCmd.SetOut(bufOut) @@ -140,8 +174,7 @@ func TestRunUndo_NoBakRepo(t *testing.T) { err := rootCmd.Execute() if err == nil { - t.Log("undo succeeded β€” .bak repo may already exist") - return + t.Fatalf("expected error in empty isolated home without bak repo, got nil (stdout: %q, stderr: %q)", bufOut.String(), bufErr.String()) } errStr := err.Error() @@ -151,6 +184,12 @@ func TestRunUndo_NoBakRepo(t *testing.T) { } func TestRunUndo_ExtraArgs(t *testing.T) { + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + configtest.SetConfigHome(t, t.TempDir()) bufOut := new(bytes.Buffer) bufErr := new(bytes.Buffer) rootCmd.SetOut(bufOut) diff --git a/cmd/verify_test.go b/cmd/verify_test.go index e81d782..260b5ec 100644 --- a/cmd/verify_test.go +++ b/cmd/verify_test.go @@ -6,12 +6,12 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "testing" "github.com/spf13/cobra" + configtest "github.com/danielxxomg/bak-cli/internal/config/testutil" "github.com/danielxxomg/bak-cli/internal/manifest" ) @@ -95,12 +95,7 @@ func TestVerifyCmd_Args(t *testing.T) { func TestRunVerify_Success(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) id := "20250101-120000" stageVerifyBackup(t, tmpDir, id, 2) @@ -123,12 +118,7 @@ func TestRunVerify_Success(t *testing.T) { func TestRunVerify_CorruptedFile(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) id := "20250101-120000" backupDir := stageVerifyBackup(t, tmpDir, id, 2) @@ -150,12 +140,7 @@ func TestRunVerify_CorruptedFile(t *testing.T) { func TestRunVerify_MissingBackup(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -170,12 +155,7 @@ func TestRunVerify_MissingBackup(t *testing.T) { func TestRunVerify_TraversalBlocked(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) deps, _, _ := setupTestDeps(t) cmd := &cobra.Command{} @@ -190,12 +170,7 @@ func TestRunVerify_TraversalBlocked(t *testing.T) { func TestRunVerify_VerboseOutput(t *testing.T) { tmpDir := t.TempDir() - switch runtime.GOOS { - case "windows": - t.Setenv("USERPROFILE", tmpDir) - default: - t.Setenv("HOME", tmpDir) - } + configtest.SetConfigHome(t, tmpDir) id := "20250101-120000" stageVerifyBackup(t, tmpDir, id, 1) diff --git a/cmd/wizard.go b/cmd/wizard.go index ead1432..b0d171a 100644 --- a/cmd/wizard.go +++ b/cmd/wizard.go @@ -11,12 +11,21 @@ import ( "github.com/danielxxomg/bak-cli/internal/tui/screens" ) +// realIsTTY is the actual terminal probe. +var realIsTTY = func() bool { + return isatty.IsTerminal(os.Stdin.Fd()) +} + // isTTY reports whether stdin is a terminal. // Exposed as a package-level variable so tests can override it // (follows the var execCommand pattern from AGENTS.md). -var isTTY = func() bool { - return isatty.IsTerminal(os.Stdin.Fd()) -} +// +// cmd tests force this to false for the whole package: on Windows CI runners +// the real probe can report a terminal, which would let a unit test reach one +// of the unguarded tea.NewProgram call sites and block on input until the test +// binary times out. Individual tests that need the interactive path override +// it back to true explicitly. +var isTTY = realIsTTY // runWizardProgram is the injection point for running the interactive // wizard Bubble Tea program. Tests override this to return a pre-built diff --git a/cmd/wizard_test.go b/cmd/wizard_test.go index 7b25c39..3f409a8 100644 --- a/cmd/wizard_test.go +++ b/cmd/wizard_test.go @@ -125,12 +125,18 @@ func (otherModel) View() tea.View { return tea.NewView("") func TestLaunchWizard(t *testing.T) { origTTY := isTTY origRun := runWizardProgram - defer func() { + t.Cleanup(func() { isTTY = origTTY runWizardProgram = origRun - }() + }) t.Run("not a TTY returns terminal error", func(t *testing.T) { + subTTY := isTTY + subRun := runWizardProgram + t.Cleanup(func() { + isTTY = subTTY + runWizardProgram = subRun + }) isTTY = func() bool { return false } runWizardProgram = func(m tea.Model) (tea.Model, error) { t.Fatal("program must not run without a TTY") @@ -146,6 +152,12 @@ func TestLaunchWizard(t *testing.T) { }) t.Run("program run error is wrapped", func(t *testing.T) { + subTTY := isTTY + subRun := runWizardProgram + t.Cleanup(func() { + isTTY = subTTY + runWizardProgram = subRun + }) isTTY = func() bool { return true } runWizardProgram = func(m tea.Model) (tea.Model, error) { return nil, errors.New("boom") @@ -160,6 +172,12 @@ func TestLaunchWizard(t *testing.T) { }) t.Run("unexpected model type returns error", func(t *testing.T) { + subTTY := isTTY + subRun := runWizardProgram + t.Cleanup(func() { + isTTY = subTTY + runWizardProgram = subRun + }) isTTY = func() bool { return true } runWizardProgram = func(m tea.Model) (tea.Model, error) { return otherModel{}, nil @@ -174,6 +192,12 @@ func TestLaunchWizard(t *testing.T) { }) t.Run("returns selections and wizard model", func(t *testing.T) { + subTTY := isTTY + subRun := runWizardProgram + t.Cleanup(func() { + isTTY = subTTY + runWizardProgram = subRun + }) isTTY = func() bool { return true } wm := &screens.WizardModel{ Confirmed: true, diff --git a/internal/actions/interfaces.go b/internal/actions/interfaces.go index 5bf3a4f..5440786 100644 --- a/internal/actions/interfaces.go +++ b/internal/actions/interfaces.go @@ -18,6 +18,9 @@ type FileSystem interface { // Stat returns file info for the given path. Stat(path string) (os.FileInfo, error) + // Lstat returns file info for the given path without following symlinks. + Lstat(path string) (os.FileInfo, error) + // ReadDir reads the directory named by dirname. ReadDir(dirname string) ([]os.DirEntry, error) @@ -30,6 +33,9 @@ type FileSystem interface { // CopyFile copies a file from src to dst. CopyFile(src, dst string) error + // Remove removes the named file or (empty) directory. + Remove(name string) error + // RemoveAll removes path and any children it contains. RemoveAll(path string) error diff --git a/internal/actions/list_backups.go b/internal/actions/list_backups.go new file mode 100644 index 0000000..6b43f78 --- /dev/null +++ b/internal/actions/list_backups.go @@ -0,0 +1,144 @@ +package actions + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/danielxxomg/bak-cli/internal/backup" + "github.com/danielxxomg/bak-cli/internal/manifest" +) + +// BackupInfo represents summary metadata for a single local backup. +type BackupInfo struct { + ID string + Date string + Size string + Status string + Cloud string +} + +// ListBackupsAction scans the backups directory and loads manifests into +// BackupInfo summaries. It supports struct-field injection and provides a +// usable zero value. +type ListBackupsAction struct { + // FS is the filesystem interface used for directory traversal and reading + // manifests. Defaults to OSFileSystem when nil. + FS FileSystem + + // BakDir is the root bak data directory containing the "backups" subfolder. + // When empty, defaults to backup.BakDir(). + BakDir string + + // FilterInvalidIDs restricts results to valid backup IDs (YYYYMMDD-HHMMSS). + // When false (default), any directory containing a valid manifest is included. + FilterInvalidIDs bool + + // SortDescending orders backups newest-first when true. When false (default), + // backups are ordered ascending by directory name. + SortDescending bool +} + +// Run executes the backup listing workflow and returns the slice of backups. +func (a *ListBackupsAction) Run() ([]BackupInfo, error) { + fsys := a.FS + if fsys == nil { + fsys = &OSFileSystem{} + } + + bakDir := a.BakDir + if bakDir == "" { + var err error + bakDir, err = backup.BakDir() + if err != nil { + return nil, fmt.Errorf("bak dir: %w", err) + } + } + + backupsDir := filepath.Join(bakDir, "backups") + entries, err := fsys.ReadDir(backupsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read backups dir: %w", err) + } + + sortedEntries := make([]os.DirEntry, len(entries)) + copy(sortedEntries, entries) + if a.SortDescending { + sort.Slice(sortedEntries, func(i, j int) bool { + return sortedEntries[i].Name() > sortedEntries[j].Name() + }) + } else { + sort.Slice(sortedEntries, func(i, j int) bool { + return sortedEntries[i].Name() < sortedEntries[j].Name() + }) + } + + var result []BackupInfo + for _, entry := range sortedEntries { + if !entry.IsDir() { + continue + } + info, ok := a.loadBackupInfo(fsys, backupsDir, entry.Name()) + if !ok { + continue + } + result = append(result, info) + } + + return result, nil +} + +// loadBackupInfo attempts to load and parse a manifest for entryName. Returns false +// when the directory should be skipped (filtered out, missing, or corrupt). +func (a *ListBackupsAction) loadBackupInfo(fsys FileSystem, backupsDir, entryName string) (BackupInfo, bool) { + if a.FilterInvalidIDs && !IsValidBackupID(entryName) { + return BackupInfo{}, false + } + + manifestPath := filepath.Join(backupsDir, entryName, "manifest.json") + data, err := fsys.ReadFile(manifestPath) + if err != nil { + return BackupInfo{}, false + } + + var m manifest.Manifest + if err := json.Unmarshal(data, &m); err != nil { + return BackupInfo{}, false + } + + backupID := m.ID + if backupID == "" { + backupID = entryName + } + + if a.FilterInvalidIDs && !IsValidBackupID(backupID) { + return BackupInfo{}, false + } + + return BackupInfo{ + ID: backupID, + Date: formatBackupDate(backupID), + Size: FormatSizeBytes(m.TotalSize), + Status: "ok", + Cloud: resolveCloudHint(m.Adapters), + }, true +} + +// resolveCloudHint returns the first adapter name sorted alphabetically, or "none". +func resolveCloudHint(adapters map[string]manifest.AdapterManifest) string { + if len(adapters) == 0 { + return "none" + } + adapterNames := make([]string, 0, len(adapters)) + for name := range adapters { + adapterNames = append(adapterNames, name) + } + sort.Strings(adapterNames) + return adapterNames[0] +} diff --git a/internal/actions/list_backups_test.go b/internal/actions/list_backups_test.go new file mode 100644 index 0000000..8020af8 --- /dev/null +++ b/internal/actions/list_backups_test.go @@ -0,0 +1,374 @@ +package actions + +import ( + "errors" + "os" + "path/filepath" + "testing" + + configtest "github.com/danielxxomg/bak-cli/internal/config/testutil" +) + +func TestListBackupsAction_EmptyDirectory(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + tests := []struct { + name string + setup func(t *testing.T, bakDir string) + wantCount int + }{ + { + name: "missing_backups_dir_returns_nil", + setup: func(t *testing.T, bakDir string) {}, + wantCount: 0, + }, + { + name: "empty_backups_dir_returns_empty", + setup: func(t *testing.T, bakDir string) { + if err := os.MkdirAll(filepath.Join(bakDir, "backups"), 0755); err != nil { + t.Fatal(err) + } + }, + wantCount: 0, + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + bakDir := t.TempDir() + tt.setup(t, bakDir) + + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(backups) != tt.wantCount { + t.Errorf("len(backups) = %d, want %d", len(backups), tt.wantCount) + } + }) + } +} + +func TestListBackupsAction_CorruptManifest(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + bakDir := t.TempDir() + backupsDir := filepath.Join(bakDir, "backups") + + // Valid backup + validDir := filepath.Join(backupsDir, "20260101-120000") + if err := os.MkdirAll(validDir, 0755); err != nil { + t.Fatal(err) + } + validManifest := `{ + "version": "0.3.0", + "id": "20260101-120000", + "created_at": "2026-01-01T12:00:00Z", + "os_source": "linux", + "bak_version": "1.0.0", + "preset": "quick", + "categories": ["config"], + "adapters": { + "opencode": { + "config_dir": "~/.config/opencode", + "items": [] + } + }, + "secrets_excluded": false, + "file_count": 5, + "total_size": 1024 + }` + if err := os.WriteFile(filepath.Join(validDir, "manifest.json"), []byte(validManifest), 0644); err != nil { + t.Fatal(err) + } + + // Corrupt manifest + corruptDir := filepath.Join(backupsDir, "20260102-120000") + if err := os.MkdirAll(corruptDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(corruptDir, "manifest.json"), []byte("invalid json {{"), 0644); err != nil { + t.Fatal(err) + } + + // Missing manifest + missingDir := filepath.Join(backupsDir, "20260103-120000") + if err := os.MkdirAll(missingDir, 0755); err != nil { + t.Fatal(err) + } + + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(backups) != 1 { + t.Fatalf("len(backups) = %d, want 1 (corrupt and missing skipped)", len(backups)) + } + if backups[0].ID != "20260101-120000" { + t.Errorf("backups[0].ID = %q, want %q", backups[0].ID, "20260101-120000") + } + if backups[0].Cloud != "opencode" { + t.Errorf("backups[0].Cloud = %q, want %q", backups[0].Cloud, "opencode") + } + if backups[0].Status != "ok" { + t.Errorf("backups[0].Status = %q, want %q", backups[0].Status, "ok") + } + if backups[0].Size != "1.0 KB" { + t.Errorf("backups[0].Size = %q, want %q", backups[0].Size, "1.0 KB") + } +} + +func TestListBackupsAction_InvalidBackupIDFiltering(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + bakDir := t.TempDir() + backupsDir := filepath.Join(bakDir, "backups") + + // Standard valid ID format: YYYYMMDD-HHMMSS + validDir := filepath.Join(backupsDir, "20260101-120000") + if err := os.MkdirAll(validDir, 0755); err != nil { + t.Fatal(err) + } + validManifest := `{ + "version": "0.3.0", + "id": "20260101-120000", + "created_at": "2026-01-01T12:00:00Z", + "os_source": "linux", + "bak_version": "1.0.0", + "preset": "quick", + "categories": ["config"], + "adapters": {}, + "secrets_excluded": false, + "file_count": 1, + "total_size": 512 + }` + if err := os.WriteFile(filepath.Join(validDir, "manifest.json"), []byte(validManifest), 0644); err != nil { + t.Fatal(err) + } + + // Custom/non-standard ID format + customDir := filepath.Join(backupsDir, "named-backup") + if err := os.MkdirAll(customDir, 0755); err != nil { + t.Fatal(err) + } + customManifest := `{ + "version": "0.3.0", + "id": "named-backup", + "created_at": "2026-01-01T12:00:00Z", + "os_source": "linux", + "bak_version": "1.0.0", + "preset": "quick", + "categories": ["config"], + "adapters": {}, + "secrets_excluded": false, + "file_count": 1, + "total_size": 512 + }` + if err := os.WriteFile(filepath.Join(customDir, "manifest.json"), []byte(customManifest), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + filterInvalidIDs bool + wantIDs []string + }{ + { + name: "without_filter_returns_all_valid_manifests", + filterInvalidIDs: false, + wantIDs: []string{"20260101-120000", "named-backup"}, + }, + { + name: "with_filter_returns_only_valid_backup_ids", + filterInvalidIDs: true, + wantIDs: []string{"20260101-120000"}, + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + FilterInvalidIDs: tt.filterInvalidIDs, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(backups) != len(tt.wantIDs) { + t.Fatalf("len(backups) = %d, want %d", len(backups), len(tt.wantIDs)) + } + for i, want := range tt.wantIDs { + if backups[i].ID != want { + t.Errorf("backups[%d].ID = %q, want %q", i, backups[i].ID, want) + } + } + }) + } +} + +func TestListBackupsAction_DeterministicOrdering(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + bakDir := t.TempDir() + backupsDir := filepath.Join(bakDir, "backups") + + ids := []string{"20260102-120000", "20260101-120000", "20260103-120000"} + for _, id := range ids { + dir := filepath.Join(backupsDir, id) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + manifestContent := `{"version":"0.3.0","id":"` + id + `","adapters":{}}` + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifestContent), 0644); err != nil { + t.Fatal(err) + } + } + + tests := []struct { + name string + sortDescending bool + wantOrder []string + }{ + { + name: "default_ascending_order", + sortDescending: false, + wantOrder: []string{"20260101-120000", "20260102-120000", "20260103-120000"}, + }, + { + name: "descending_order", + sortDescending: true, + wantOrder: []string{"20260103-120000", "20260102-120000", "20260101-120000"}, + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + SortDescending: tt.sortDescending, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(backups) != len(tt.wantOrder) { + t.Fatalf("len(backups) = %d, want %d", len(backups), len(tt.wantOrder)) + } + for i, want := range tt.wantOrder { + if backups[i].ID != want { + t.Errorf("backups[%d].ID = %q, want %q", i, backups[i].ID, want) + } + } + }) + } +} + +func TestListBackupsAction_AdapterCloudHintDeterministic(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + bakDir := t.TempDir() + backupsDir := filepath.Join(bakDir, "backups") + + // Multi-adapter backup: ghostty and neovim + multiDir := filepath.Join(backupsDir, "20260101-120000") + if err := os.MkdirAll(multiDir, 0755); err != nil { + t.Fatal(err) + } + multiManifest := `{ + "version": "0.3.0", + "id": "20260101-120000", + "adapters": { + "neovim": {"config_dir": "~/.config/nvim", "items": []}, + "ghostty": {"config_dir": "~/.config/ghostty", "items": []} + } + }` + if err := os.WriteFile(filepath.Join(multiDir, "manifest.json"), []byte(multiManifest), 0644); err != nil { + t.Fatal(err) + } + + // Zero-adapter backup + zeroDir := filepath.Join(backupsDir, "20260102-120000") + if err := os.MkdirAll(zeroDir, 0755); err != nil { + t.Fatal(err) + } + zeroManifest := `{"version": "0.3.0", "id": "20260102-120000", "adapters": {}}` + if err := os.WriteFile(filepath.Join(zeroDir, "manifest.json"), []byte(zeroManifest), 0644); err != nil { + t.Fatal(err) + } + + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(backups) != 2 { + t.Fatalf("len(backups) = %d, want 2", len(backups)) + } + + // Alphabetically sorted adapter names -> "ghostty" comes before "neovim" + if backups[0].Cloud != "ghostty" { + t.Errorf("backups[0].Cloud = %q, want %q", backups[0].Cloud, "ghostty") + } + if backups[1].Cloud != "none" { + t.Errorf("backups[1].Cloud = %q, want %q", backups[1].Cloud, "none") + } +} + +func TestListBackupsAction_SkipsNonDirectories(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + bakDir := t.TempDir() + backupsDir := filepath.Join(bakDir, "backups") + if err := os.MkdirAll(backupsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backupsDir, "not-a-dir.txt"), []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + action := ListBackupsAction{ + FS: &OSFileSystem{}, + BakDir: bakDir, + } + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(backups) != 0 { + t.Errorf("len(backups) = %d, want 0", len(backups)) + } +} + +func TestListBackupsAction_ReadDirError(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state isolation pending + mockFS := &MockFileSystem{ + ReadDirErrors: map[string]error{ + filepath.Join("/mock/bak", "backups"): errors.New("simulated I/O failure"), + }, + } + + action := ListBackupsAction{ + FS: mockFS, + BakDir: "/mock/bak", + } + _, err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestListBackupsAction_ZeroValueUsable(t *testing.T) { //nolint:paralleltest // sets environment variables via SetConfigHome + home := t.TempDir() + configtest.SetConfigHome(t, home) + + var action ListBackupsAction + backups, err := action.Run() + if err != nil { + t.Fatalf("unexpected error for zero-value action: %v", err) + } + if len(backups) != 0 { + t.Errorf("expected 0 backups on fresh home, got %d", len(backups)) + } +} diff --git a/internal/actions/mock_impl_test.go b/internal/actions/mock_impl_test.go index c292513..3068c06 100644 --- a/internal/actions/mock_impl_test.go +++ b/internal/actions/mock_impl_test.go @@ -14,6 +14,7 @@ import ( type MockFileSystem struct { HomeDir string StatResult map[string]MockStatResult + LstatResult map[string]MockStatResult DirEntries map[string][]os.DirEntry ReadDirErrors map[string]error Files map[string][]byte @@ -49,6 +50,16 @@ func (m *MockFileSystem) Stat(path string) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (m *MockFileSystem) Lstat(path string) (os.FileInfo, error) { + if r, ok := m.LstatResult[path]; ok { + return r.Info, r.Err + } + if r, ok := m.StatResult[path]; ok { + return r.Info, r.Err + } + return nil, os.ErrNotExist +} + func (m *MockFileSystem) ReadDir(dirname string) ([]os.DirEntry, error) { if err, ok := m.ReadDirErrors[dirname]; ok { return nil, err @@ -93,6 +104,15 @@ func (m *MockFileSystem) CopyFile(src, dst string) error { return nil } +func (m *MockFileSystem) Remove(name string) error { + m.RemoveAllCalls++ + if err, ok := m.RemoveErrors[name]; ok { + return err + } + delete(m.Files, name) + return nil +} + func (m *MockFileSystem) RemoveAll(path string) error { m.RemoveAllCalls++ if err, ok := m.RemoveErrors[path]; ok { diff --git a/internal/actions/os_impl.go b/internal/actions/os_impl.go index 6732042..cb9c2ae 100644 --- a/internal/actions/os_impl.go +++ b/internal/actions/os_impl.go @@ -22,6 +22,7 @@ type OSFileSystem struct{} // Compile-time check. var _ FileSystem = (*OSFileSystem)(nil) +// UserHomeDir returns the current user's home directory. func (o *OSFileSystem) UserHomeDir() (string, error) { dir, err := os.UserHomeDir() if err != nil { @@ -30,6 +31,7 @@ func (o *OSFileSystem) UserHomeDir() (string, error) { return dir, nil } +// Stat returns file info for path, following symlinks. func (o *OSFileSystem) Stat(path string) (os.FileInfo, error) { info, err := os.Stat(path) if err != nil { @@ -38,6 +40,16 @@ func (o *OSFileSystem) Stat(path string) (os.FileInfo, error) { return info, nil } +// Lstat returns file info for path without following symlinks. +func (o *OSFileSystem) Lstat(path string) (os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("lstat: %w", err) + } + return info, nil +} + +// ReadDir lists the entries of dirname, sorted by filename. func (o *OSFileSystem) ReadDir(dirname string) ([]os.DirEntry, error) { entries, err := os.ReadDir(dirname) if err != nil { @@ -46,6 +58,7 @@ func (o *OSFileSystem) ReadDir(dirname string) ([]os.DirEntry, error) { return entries, nil } +// ReadFile returns the contents of filename. func (o *OSFileSystem) ReadFile(filename string) ([]byte, error) { data, err := os.ReadFile(filename) if err != nil { @@ -54,6 +67,7 @@ func (o *OSFileSystem) ReadFile(filename string) ([]byte, error) { return data, nil } +// MkdirAll creates dirname and any missing parents with the given permissions. func (o *OSFileSystem) MkdirAll(path string, perm os.FileMode) error { if err := os.MkdirAll(path, perm); err != nil { return fmt.Errorf("mkdir: %w", err) @@ -61,12 +75,19 @@ func (o *OSFileSystem) MkdirAll(path string, perm os.FileMode) error { return nil } -func (o *OSFileSystem) CopyFile(src, dst string) error { +// CopyFile copies src to dst, creating parent directories as needed and +// preserving the source permission bits. Close errors are propagated when no +// earlier error occurred. +func (o *OSFileSystem) CopyFile(src, dst string) (retErr error) { sf, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } - defer func() { _ = sf.Close() }() + defer func() { + if closeErr := sf.Close(); closeErr != nil && retErr == nil { + retErr = fmt.Errorf("close source: %w", closeErr) + } + }() info, err := sf.Stat() if err != nil { @@ -81,16 +102,16 @@ func (o *OSFileSystem) CopyFile(src, dst string) error { if err != nil { return fmt.Errorf("create destination: %w", err) } - defer func() { _ = df.Close() }() + defer func() { + if closeErr := df.Close(); closeErr != nil && retErr == nil { + retErr = fmt.Errorf("close destination: %w", closeErr) + } + }() if _, err := io.Copy(df, sf); err != nil { return fmt.Errorf("copy: %w", err) } - if err := df.Close(); err != nil { - return fmt.Errorf("close destination: %w", err) - } - if err := os.Chmod(dst, info.Mode().Perm()); err != nil && !isWindows() { return fmt.Errorf("chmod destination: %w", err) } @@ -98,6 +119,16 @@ func (o *OSFileSystem) CopyFile(src, dst string) error { return nil } +// Remove deletes a single file or empty directory. +func (o *OSFileSystem) Remove(name string) error { + if err := os.Remove(name); err != nil { + return fmt.Errorf("remove: %w", err) + } + return nil +} + +// RemoveAll deletes path and any children. Callers must validate path safety +// before invoking it. func (o *OSFileSystem) RemoveAll(path string) error { if err := os.RemoveAll(path); err != nil { return fmt.Errorf("remove: %w", err) @@ -105,6 +136,7 @@ func (o *OSFileSystem) RemoveAll(path string) error { return nil } +// WalkDir walks the file tree rooted at root, calling fn for each entry. func (o *OSFileSystem) WalkDir(root string, fn fs.WalkDirFunc) error { if err := filepath.WalkDir(root, fn); err != nil { return fmt.Errorf("walk dir: %w", err) @@ -112,6 +144,7 @@ func (o *OSFileSystem) WalkDir(root string, fn fs.WalkDirFunc) error { return nil } +// WriteFile writes data to filename with the given permissions. func (o *OSFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { if err := os.WriteFile(filename, data, perm); err != nil { return fmt.Errorf("write file: %w", err) @@ -119,6 +152,7 @@ func (o *OSFileSystem) WriteFile(filename string, data []byte, perm os.FileMode) return nil } +// Chmod sets the permission bits of name. func (o *OSFileSystem) Chmod(name string, mode os.FileMode) error { if err := os.Chmod(name, mode); err != nil { return fmt.Errorf("chmod: %w", err) @@ -141,7 +175,7 @@ var _ ConfigLoader = (*RealConfigLoader)(nil) func (r *RealConfigLoader) Load() (*Config, error) { cfg, err := configLoad() if err != nil { - return nil, err + return nil, fmt.Errorf("load config: %w", err) } return &Config{ SchemaVersion: cfg.SchemaVersion, diff --git a/internal/actions/os_impl_test.go b/internal/actions/os_impl_test.go index 339d7f5..14a1ac1 100644 --- a/internal/actions/os_impl_test.go +++ b/internal/actions/os_impl_test.go @@ -27,6 +27,50 @@ func TestOSFileSystem_Stat_NotFound(t *testing.T) { //nolint:paralleltest // not } } +func TestOSFileSystem_Lstat_HappyPath(t *testing.T) { //nolint:paralleltest // shared state + fsys := &OSFileSystem{} + tmpDir := t.TempDir() + info, err := fsys.Lstat(tmpDir) + if err != nil { + t.Fatalf("Lstat: %v", err) + } + if !info.IsDir() { + t.Error("expected directory") + } +} + +func TestOSFileSystem_Lstat_NotFound(t *testing.T) { //nolint:paralleltest // shared state + fsys := &OSFileSystem{} + _, err := fsys.Lstat(filepath.Join(t.TempDir(), "nonexistent")) + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestOSFileSystem_Lstat_Symlink(t *testing.T) { //nolint:paralleltest // shared state + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated privileges on Windows") + } + fsys := &OSFileSystem{} + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "target.txt") + if err := os.WriteFile(target, []byte("target"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(tmpDir, "link.txt") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + info, err := fsys.Lstat(link) + if err != nil { + t.Fatalf("Lstat: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("expected symlink mode bit") + } +} + func TestOSFileSystem_ReadDir_HappyPath(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending fsys := &OSFileSystem{} tmpDir := t.TempDir() @@ -110,6 +154,21 @@ func TestOSFileSystem_CopyFile_SourceNotFound(t *testing.T) { //nolint:parallelt } } +func TestOSFileSystem_Remove(t *testing.T) { //nolint:paralleltest // shared state + fsys := &OSFileSystem{} + tmpDir := t.TempDir() + p := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(p, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := fsys.Remove(p); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Error("file should be removed") + } +} + func TestOSFileSystem_RemoveAll(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending fsys := &OSFileSystem{} tmpDir := t.TempDir() diff --git a/internal/actions/recovery.go b/internal/actions/recovery.go new file mode 100644 index 0000000..6f7b7ac --- /dev/null +++ b/internal/actions/recovery.go @@ -0,0 +1,934 @@ +package actions + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/danielxxomg/bak-cli/internal/git" + "github.com/danielxxomg/bak-cli/internal/paths" + restorepkg "github.com/danielxxomg/bak-cli/internal/restore" + gogit "github.com/go-git/go-git/v5" +) + +const ( + statusPrepared = "prepared" + statusApplied = "applied" + statusRolledBack = "rolled_back" + statusFailed = "failed" + statusUndone = "undone" +) + +// targetPreState stores original state of a target file prior to any restore write. +type targetPreState struct { + TargetPath string `json:"target_path"` + RelPath string `json:"rel_path"` + Existed bool `json:"existed"` + Mode os.FileMode `json:"mode"` + SHA256 string `json:"sha256,omitempty"` +} + +// targetPostState stores actual target state after restore mutation. +type targetPostState struct { + TargetPath string `json:"target_path"` + RelPath string `json:"rel_path"` + Existed bool `json:"existed"` + Mode os.FileMode `json:"mode"` + SHA256 string `json:"sha256,omitempty"` +} + +// recoveryMetadata documents the recovery point. +type recoveryMetadata struct { + PointID string `json:"point_id"` + BackupID string `json:"backup_id"` + CreatedAt time.Time `json:"created_at"` + TargetPreMap map[string]targetPreState `json:"target_pre_map"` + TargetPostMap map[string]targetPostState `json:"target_post_map,omitempty"` + Status string `json:"status"` // prepared, applied, rolled_back, failed +} + +// rollbackOutcome captures detailed results of a rollback attempt. +type rollbackOutcome struct { + PointID string + Reverted []string + Unresolved []string + Errors []error +} + +// recoveryManager manages private local recovery snapshots under ~/.bak/recovery/. +type recoveryManager struct { + FS FileSystem // file system for targets + StorageFS FileSystem // file system for recovery repository (defaults to OSFileSystem) + HomeDir string + BackupID string + PointID string + PointDir string + RepoDir string + RecoveryDir string // optional base recovery directory override + Meta recoveryMetadata +} + +// sanitizePath replaces home directory prefix with ~ to prevent username leakage. +func sanitizePath(p, homeDir string) string { + cleanP := paths.CanonicalPath(p) + cleanHome := paths.CanonicalPath(homeDir) + if cleanP == cleanHome { + return "~" + } + if strings.HasPrefix(cleanP, cleanHome+"/") { + return "~/" + strings.TrimPrefix(cleanP, cleanHome+"/") + } + return filepath.Base(cleanP) +} + +type safePathError struct { + op string + path string + err error +} + +func (e *safePathError) Error() string { + return fmt.Sprintf("%s %s: %v", e.op, e.path, e.err) +} + +func (e *safePathError) Unwrap() error { + return e.err +} + +type safeError struct { + err error + msg string +} + +func (e *safeError) Error() string { + return e.msg +} + +func (e *safeError) Unwrap() error { + return e.err +} + +func sanitizeError(err error, homeDir string) error { + if err == nil { + return nil + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return &safePathError{ + op: pathErr.Op, + path: sanitizePath(pathErr.Path, homeDir), + err: sanitizeError(pathErr.Err, homeDir), + } + } + if homeDir != "" { + cleanHome := paths.CanonicalPath(homeDir) + msg := err.Error() + if strings.Contains(msg, cleanHome) || strings.Contains(msg, homeDir) { + cleanMsg := strings.ReplaceAll(msg, cleanHome, "~") + cleanMsg = strings.ReplaceAll(cleanMsg, homeDir, "~") + return &safeError{err: err, msg: cleanMsg} + } + } + return err +} + +// generateRecoveryID generates an opaque, collision-resistant recovery ID. +func generateRecoveryID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate recovery id: %w", err) + } + return fmt.Sprintf("rec-%s-%s", time.Now().UTC().Format("20060102T150405Z"), hex.EncodeToString(b)), nil +} + +// validateNoSymlinkAncestors verifies that path and all its ancestor components are not symlinks. +func validateNoSymlinkAncestors(fs FileSystem, homeDir, targetPath string) error { + cleanTarget := paths.CanonicalPath(targetPath) + cleanHome := paths.CanonicalPath(homeDir) + + if cleanTarget != cleanHome && !strings.HasPrefix(cleanTarget, cleanHome+"/") { + return fmt.Errorf("target path %s escapes home directory", sanitizePath(targetPath, homeDir)) + } + + rel, err := filepath.Rel(cleanHome, cleanTarget) + if err != nil { + return fmt.Errorf("relative path: %w", err) + } + + parts := strings.Split(rel, string(filepath.Separator)) + curr := cleanHome + for _, p := range parts { + if p == "" || p == "." { + continue + } + curr = filepath.Join(curr, p) + info, err := fs.Lstat(curr) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + break + } + return fmt.Errorf("lstat %s: %w", sanitizePath(curr, homeDir), sanitizeError(err, homeDir)) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink detected in path component: %s", sanitizePath(curr, homeDir)) + } + } + return nil +} + +func (rm *recoveryManager) initDefaults() error { + if rm.HomeDir == "" { + return fmt.Errorf("home directory cannot be empty") + } + if rm.StorageFS == nil { + rm.StorageFS = &OSFileSystem{} + } + if rm.PointID == "" { + id, err := generateRecoveryID() + if err != nil { + return err + } + rm.PointID = id + } + + recoveryBase := rm.RecoveryDir + if recoveryBase == "" { + recoveryBase = filepath.Join(rm.HomeDir, ".bak", "recovery") + } + + cleanBase := paths.CanonicalPath(recoveryBase) + cleanHome := paths.CanonicalPath(rm.HomeDir) + + if cleanBase == cleanHome || cleanBase == cleanHome+"/" || cleanBase == paths.CanonicalPath(filepath.Join(rm.HomeDir, ".bak")) { + return fmt.Errorf("recovery directory cannot be home or .bak root: %s", sanitizePath(recoveryBase, rm.HomeDir)) + } + if !strings.HasPrefix(cleanBase, cleanHome+"/") { + return fmt.Errorf("recovery directory %s escapes home directory", sanitizePath(recoveryBase, rm.HomeDir)) + } + + if err := validateNoSymlinkAncestors(rm.StorageFS, rm.HomeDir, recoveryBase); err != nil { + return fmt.Errorf("validate recovery storage %s: %w", sanitizePath(recoveryBase, rm.HomeDir), err) + } + + rm.PointDir = filepath.Join(cleanBase, rm.PointID) + rm.RepoDir = filepath.Join(rm.PointDir, "repo") + + if rm.Meta.TargetPreMap == nil { + rm.Meta = recoveryMetadata{ + PointID: rm.PointID, + BackupID: rm.BackupID, + CreatedAt: time.Now().UTC(), + TargetPreMap: make(map[string]targetPreState), + Status: statusPrepared, + } + } + return nil +} + +// opaquePayloadName returns a deterministic flat filename derived from the SHA-256 +// hash of the canonical relative path, preventing control name conflicts (such as +// nested .git directories or captured .gitignore files). +func opaquePayloadName(relSlash string) string { + cleanRel := path.Clean(strings.ReplaceAll(relSlash, "\\", "/")) + h := sha256.Sum256([]byte(cleanRel)) + return fmt.Sprintf("%x", h) +} + +// capturePreTarget captures an individual target's pre-state into the recovery repository. +func (rm *recoveryManager) capturePreTarget(d restorepkg.FileDiff, cleanHome string) error { + cleanTarget := paths.CanonicalPath(d.TargetPath) + rel, err := filepath.Rel(cleanHome, cleanTarget) + if err != nil { + return fmt.Errorf("rel target path: %w", err) + } + relSlash := path.Clean(strings.ReplaceAll(rel, "\\", "/")) + if strings.HasPrefix(relSlash, "../") || relSlash == ".." { + return fmt.Errorf("target path escapes home directory: %s", sanitizePath(d.TargetPath, rm.HomeDir)) + } + repoDestPath := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName(relSlash)) + + preState := targetPreState{ + TargetPath: d.TargetPath, + RelPath: relSlash, + } + + info, err := rm.FS.Lstat(d.TargetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + preState.Existed = false + rm.Meta.TargetPreMap[cleanTarget] = preState + return nil + } + return fmt.Errorf("lstat target %s: %w", sanitizePath(d.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink target: %s", sanitizePath(d.TargetPath, rm.HomeDir)) + } + preState.Existed = true + preState.Mode = info.Mode().Perm() + + data, err := rm.FS.ReadFile(d.TargetPath) + if err != nil { + return fmt.Errorf("read target %s: %w", sanitizePath(d.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + h := sha256.Sum256(data) + preState.SHA256 = fmt.Sprintf("sha256:%x", h) + + if err := rm.StorageFS.MkdirAll(filepath.Dir(repoDestPath), 0700); err != nil { + return fmt.Errorf("mkdir recovery repo dest: %w", sanitizeError(err, rm.HomeDir)) + } + if err := rm.StorageFS.WriteFile(repoDestPath, data, 0600); err != nil { + return fmt.Errorf("write recovery repo file: %w", sanitizeError(err, rm.HomeDir)) + } + + rm.Meta.TargetPreMap[cleanTarget] = preState + return nil +} + +// filterAffectedTargets verifies targets and filters down to DiffNew / DiffModified diffs. +func (rm *recoveryManager) filterAffectedTargets(diffs []restorepkg.FileDiff) ([]restorepkg.FileDiff, error) { + var affected []restorepkg.FileDiff + seenTargets := make(map[string]bool) + cleanRecoveryBase := paths.CanonicalPath(filepath.Dir(rm.PointDir)) + cleanPointDir := paths.CanonicalPath(rm.PointDir) + + for _, d := range diffs { + if d.Status != restorepkg.DiffNew && d.Status != restorepkg.DiffModified { + continue + } + cleanTarget := paths.CanonicalPath(d.TargetPath) + lowerTarget := strings.ToLower(cleanTarget) + if seenTargets[lowerTarget] { + return nil, fmt.Errorf("duplicate target path in restore diffs: %s", sanitizePath(d.TargetPath, rm.HomeDir)) + } + seenTargets[lowerTarget] = true + + lowerBase := strings.ToLower(cleanRecoveryBase) + lowerPoint := strings.ToLower(cleanPointDir) + if lowerTarget == lowerBase || strings.HasPrefix(lowerTarget, lowerBase+"/") || + lowerTarget == lowerPoint || strings.HasPrefix(lowerTarget, lowerPoint+"/") { + return nil, fmt.Errorf("target path overlaps recovery storage: %s", sanitizePath(d.TargetPath, rm.HomeDir)) + } + + if err := validateNoSymlinkAncestors(rm.FS, rm.HomeDir, d.TargetPath); err != nil { + return nil, fmt.Errorf("validate target path %s: %w", sanitizePath(d.TargetPath, rm.HomeDir), err) + } + + affected = append(affected, d) + } + return affected, nil +} + +func (rm *recoveryManager) initRepoDirectories() (*gogit.Repository, error) { + if err := rm.StorageFS.MkdirAll(rm.RepoDir, 0700); err != nil { + return nil, fmt.Errorf("create recovery repo dir: %w", sanitizeError(err, rm.HomeDir)) + } + if err := rm.StorageFS.Chmod(rm.PointDir, 0700); err != nil && !isWindows() { + return nil, fmt.Errorf("chmod recovery point dir: %w", sanitizeError(err, rm.HomeDir)) + } + if err := rm.StorageFS.Chmod(rm.RepoDir, 0700); err != nil && !isWindows() { + return nil, fmt.Errorf("chmod recovery repo dir: %w", sanitizeError(err, rm.HomeDir)) + } + gitRepo, err := git.InitRepo(rm.RepoDir) + if err != nil { + return nil, fmt.Errorf("init recovery git repo: %w", sanitizeError(err, rm.HomeDir)) + } + return gitRepo, nil +} + +func (rm *recoveryManager) commitMetadataAndSnapshot(gitRepo *gogit.Repository, message string) error { + manifestBytes, err := json.MarshalIndent(rm.Meta, "", " ") + if err != nil { + return fmt.Errorf("marshal recovery meta: %w", err) + } + metaFilePath := filepath.Join(rm.RepoDir, "recovery-meta.json") + if err := rm.StorageFS.WriteFile(metaFilePath, manifestBytes, 0600); err != nil { + return fmt.Errorf("write recovery meta file: %w", sanitizeError(err, rm.HomeDir)) + } + if err := git.StageAll(gitRepo); err != nil { + return fmt.Errorf("stage recovery files: %w", sanitizeError(err, rm.HomeDir)) + } + if err := git.Commit(gitRepo, message); err != nil { + return fmt.Errorf("commit %s: %w", message, sanitizeError(err, rm.HomeDir)) + } + return nil +} + +// Prepare captures pre-state for all affected targets (DiffNew and DiffModified) +// into private recovery repository and commits it. +func (rm *recoveryManager) Prepare(diffs []restorepkg.FileDiff) error { + if err := rm.initDefaults(); err != nil { + return err + } + + affected, err := rm.filterAffectedTargets(diffs) + if err != nil { + return err + } + if len(affected) == 0 { + return nil + } + + gitRepo, err := rm.initRepoDirectories() + if err != nil { + return err + } + + cleanHome := paths.CanonicalPath(rm.HomeDir) + for _, d := range affected { + if err := rm.capturePreTarget(d, cleanHome); err != nil { + return err + } + } + + return rm.commitMetadataAndSnapshot(gitRepo, "pre-restore snapshot") +} + +// capturePostTarget captures an individual target's post-state into the recovery repository. +func (rm *recoveryManager) capturePostTarget(pre targetPreState) (targetPostState, error) { + post := targetPostState{ + TargetPath: pre.TargetPath, + RelPath: pre.RelPath, + } + info, err := rm.FS.Lstat(pre.TargetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + post.Existed = false + return post, nil + } + return post, fmt.Errorf("lstat post-restore %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + post.Existed = true + post.Mode = info.Mode().Perm() + repoDestPath := filepath.Join(rm.RepoDir, "snapshots", "post", opaquePayloadName(pre.RelPath)) + data, err := rm.FS.ReadFile(pre.TargetPath) + if err != nil { + return post, fmt.Errorf("read post-restore %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + h := sha256.Sum256(data) + post.SHA256 = fmt.Sprintf("sha256:%x", h) + + if err := rm.StorageFS.MkdirAll(filepath.Dir(repoDestPath), 0700); err != nil { + return post, fmt.Errorf("mkdir post recovery dest: %w", sanitizeError(err, rm.HomeDir)) + } + if err := rm.StorageFS.WriteFile(repoDestPath, data, 0600); err != nil { + return post, fmt.Errorf("write post recovery file: %w", sanitizeError(err, rm.HomeDir)) + } + + return post, nil +} + +// RecordPostState captures the final state of target files after successful apply and commits. +func (rm *recoveryManager) RecordPostState() error { + if len(rm.Meta.TargetPreMap) == 0 { + return nil + } + + gitRepo, err := git.OpenRepo(rm.RepoDir) + if err != nil { + return fmt.Errorf("open recovery git repo: %w", err) + } + + rm.Meta.TargetPostMap = make(map[string]targetPostState) + for targetPath, pre := range rm.Meta.TargetPreMap { + post, err := rm.capturePostTarget(pre) + if err != nil { + return err + } + rm.Meta.TargetPostMap[targetPath] = post + } + + rm.Meta.Status = statusApplied + return rm.commitMetadataAndSnapshot(gitRepo, "post-restore snapshot") +} + +func (rm *recoveryManager) rollbackNewTarget(pre targetPreState) error { + info, err := rm.FS.Lstat(pre.TargetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("inspect new target %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + if info.IsDir() { + return fmt.Errorf("refusing to remove unexpected directory at target %s", sanitizePath(pre.TargetPath, rm.HomeDir)) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("refusing to remove non-regular file at target %s", sanitizePath(pre.TargetPath, rm.HomeDir)) + } + if remErr := rm.FS.Remove(pre.TargetPath); remErr != nil { + return fmt.Errorf("remove new target %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(remErr, rm.HomeDir)) + } + return nil +} + +func (rm *recoveryManager) rollbackExistingTarget(pre targetPreState) error { + repoSrcPath := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName(pre.RelPath)) + data, err := rm.StorageFS.ReadFile(repoSrcPath) + if err != nil { + return fmt.Errorf("read pre-state file %s: %w", sanitizePath(pre.RelPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + h := sha256.Sum256(data) + if fmt.Sprintf("sha256:%x", h) != pre.SHA256 { + return fmt.Errorf("pre-state file %s checksum mismatch (tampered or corrupted)", sanitizePath(pre.RelPath, rm.HomeDir)) + } + + if err := rm.FS.MkdirAll(filepath.Dir(pre.TargetPath), 0755); err != nil { + return fmt.Errorf("mkdir target dir %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + if err := rm.FS.WriteFile(pre.TargetPath, data, pre.Mode); err != nil { + return fmt.Errorf("restore target %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + if err := rm.FS.Chmod(pre.TargetPath, pre.Mode); err != nil && !isWindows() { + return fmt.Errorf("chmod target %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + return nil +} + +// rollbackTarget restores a single target file to its pre-state. +func (rm *recoveryManager) rollbackTarget(pre targetPreState) error { + if err := validateNoSymlinkAncestors(rm.FS, rm.HomeDir, pre.TargetPath); err != nil { + return fmt.Errorf("validate target path %s: %w", sanitizePath(pre.TargetPath, rm.HomeDir), err) + } + + if !pre.Existed { + return rm.rollbackNewTarget(pre) + } + return rm.rollbackExistingTarget(pre) +} + +// Rollback restores every attempted target to its pre-restore state. +func (rm *recoveryManager) Rollback(attemptedTargets []string) rollbackOutcome { + outcome := rollbackOutcome{ + PointID: rm.PointID, + } + + seen := make(map[string]bool) + var targets []string + for _, t := range attemptedTargets { + cleanT := paths.CanonicalPath(t) + if !seen[cleanT] { + seen[cleanT] = true + targets = append(targets, cleanT) + } + } + + for _, target := range targets { + pre, ok := rm.Meta.TargetPreMap[target] + if !ok { + outcome.Unresolved = append(outcome.Unresolved, target) + outcome.Errors = append(outcome.Errors, fmt.Errorf("no pre-state recorded for %s", sanitizePath(target, rm.HomeDir))) + continue + } + + if err := rm.rollbackTarget(pre); err != nil { + outcome.Unresolved = append(outcome.Unresolved, pre.TargetPath) + outcome.Errors = append(outcome.Errors, err) + continue + } + outcome.Reverted = append(outcome.Reverted, pre.TargetPath) + } + + if len(outcome.Unresolved) > 0 { + rm.Meta.Status = statusFailed + } else { + rm.Meta.Status = statusRolledBack + } + + metaBytes, err := json.MarshalIndent(rm.Meta, "", " ") + if err == nil { + metaFilePath := filepath.Join(rm.RepoDir, "recovery-meta.json") + if writeErr := rm.StorageFS.WriteFile(metaFilePath, metaBytes, 0600); writeErr != nil { + outcome.Errors = append(outcome.Errors, fmt.Errorf("write rollback metadata: %w", sanitizeError(writeErr, rm.HomeDir))) + } + } else { + outcome.Errors = append(outcome.Errors, fmt.Errorf("marshal rollback metadata: %w", err)) + } + + sort.Strings(outcome.Reverted) + sort.Strings(outcome.Unresolved) + return outcome +} + +// errNoAppliedPoint indicates that no applied recovery point was found. +var errNoAppliedPoint = errors.New("no applied recovery point found") + +// ErrNoAppliedRecoveryPoint indicates that no applied recovery point was found. +var ErrNoAppliedRecoveryPoint = errNoAppliedPoint + +func validateRecoveryBase(storageFS FileSystem, recoveryBase, homeDir string) (string, error) { + cleanBase := paths.CanonicalPath(recoveryBase) + cleanHome := paths.CanonicalPath(homeDir) + + if cleanBase == cleanHome || cleanBase == cleanHome+"/" || cleanBase == paths.CanonicalPath(filepath.Join(homeDir, ".bak")) { + return "", fmt.Errorf("recovery directory cannot be home or .bak root: %s", sanitizePath(recoveryBase, homeDir)) + } + if !strings.HasPrefix(cleanBase, cleanHome+"/") { + return "", fmt.Errorf("recovery directory %s escapes home directory", sanitizePath(recoveryBase, homeDir)) + } + + if err := validateNoSymlinkAncestors(storageFS, homeDir, recoveryBase); err != nil { + return "", fmt.Errorf("validate recovery storage %s: %w", sanitizePath(recoveryBase, homeDir), err) + } + return cleanBase, nil +} + +func readCandidateAppliedPoint(storageFS FileSystem, cleanBase string, entry os.DirEntry) (*recoveryMetadata, bool) { + if !entry.IsDir() { + return nil, false + } + pointDir := filepath.Join(cleanBase, entry.Name()) + info, err := storageFS.Lstat(pointDir) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return nil, false + } + + metaFilePath := filepath.Join(pointDir, "repo", "recovery-meta.json") + metaBytes, err := storageFS.ReadFile(metaFilePath) + if err != nil { + metaFilePath = filepath.Join(pointDir, "recovery-meta.json") + metaBytes, err = storageFS.ReadFile(metaFilePath) + if err != nil { + return nil, false + } + } + + var meta recoveryMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + return nil, false + } + if meta.Status != statusApplied { + return nil, false + } + if meta.PointID == "" { + meta.PointID = entry.Name() + } else if meta.PointID != entry.Name() { + return nil, false + } + return &meta, true +} + +// findLatestAppliedPoint scans recoveryBase for recovery points with Status == "applied", +// skips corrupt entries, sorts by CreatedAt desc (with PointID desc tiebreak), and returns +// the newest point or a typed none-found error. +func findLatestAppliedPoint(storageFS FileSystem, recoveryBase, homeDir string) (*recoveryMetadata, error) { + if homeDir == "" { + return nil, fmt.Errorf("home directory cannot be empty") + } + if storageFS == nil { + storageFS = &OSFileSystem{} + } + if recoveryBase == "" { + recoveryBase = filepath.Join(homeDir, ".bak", "recovery") + } + + cleanBase, err := validateRecoveryBase(storageFS, recoveryBase, homeDir) + if err != nil { + return nil, err + } + + entries, err := storageFS.ReadDir(cleanBase) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, errNoAppliedPoint + } + return nil, fmt.Errorf("read recovery dir %s: %w", sanitizePath(cleanBase, homeDir), sanitizeError(err, homeDir)) + } + + var candidates []recoveryMetadata + for _, entry := range entries { + if cand, ok := readCandidateAppliedPoint(storageFS, cleanBase, entry); ok { + candidates = append(candidates, *cand) + } + } + + if len(candidates) == 0 { + return nil, errNoAppliedPoint + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) { + return candidates[i].PointID > candidates[j].PointID + } + return candidates[i].CreatedAt.After(candidates[j].CreatedAt) + }) + + return &candidates[0], nil +} + +func (rm *recoveryManager) validateTargetCorrespondence(k string, post targetPostState) (targetPreState, error) { + cleanPost := paths.CanonicalPath(post.TargetPath) + if cleanPost != k { + return targetPreState{}, fmt.Errorf("target correspondence mismatch for %s: post target path %s does not match key", sanitizePath(k, rm.HomeDir), sanitizePath(post.TargetPath, rm.HomeDir)) + } + pre, ok := rm.Meta.TargetPreMap[k] + if !ok { + return targetPreState{}, fmt.Errorf("target correspondence mismatch: missing pre-state for target %s", sanitizePath(post.TargetPath, rm.HomeDir)) + } + cleanPre := paths.CanonicalPath(pre.TargetPath) + if cleanPre != k || cleanPre != cleanPost { + return targetPreState{}, fmt.Errorf("target correspondence mismatch for %s: pre target %s does not match post target %s", sanitizePath(k, rm.HomeDir), sanitizePath(pre.TargetPath, rm.HomeDir), sanitizePath(post.TargetPath, rm.HomeDir)) + } + if pre.RelPath != post.RelPath { + return targetPreState{}, fmt.Errorf("target correspondence mismatch for %s: pre rel_path %s does not match post rel_path %s", sanitizePath(k, rm.HomeDir), sanitizePath(pre.RelPath, rm.HomeDir), sanitizePath(post.RelPath, rm.HomeDir)) + } + return pre, nil +} + +func (rm *recoveryManager) verifyLiveTarget(post targetPostState) error { + info, err := rm.FS.Lstat(post.TargetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if post.Existed { + return fmt.Errorf("target drift: expected %s to exist, but file is missing", sanitizePath(post.TargetPath, rm.HomeDir)) + } + return nil + } + return fmt.Errorf("inspect target %s: %w", sanitizePath(post.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + + if !post.Existed { + return fmt.Errorf("target drift: expected %s to be absent, but file exists", sanitizePath(post.TargetPath, rm.HomeDir)) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("target drift: refusing symlink target %s", sanitizePath(post.TargetPath, rm.HomeDir)) + } + if info.IsDir() { + return fmt.Errorf("target drift: expected regular file at %s, found directory", sanitizePath(post.TargetPath, rm.HomeDir)) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("target drift: expected regular file at %s", sanitizePath(post.TargetPath, rm.HomeDir)) + } + if !isWindows() && info.Mode().Perm() != post.Mode.Perm() { + return fmt.Errorf("target drift: mode mismatch for %s (expected %04o, got %04o)", sanitizePath(post.TargetPath, rm.HomeDir), post.Mode.Perm(), info.Mode().Perm()) + } + + data, err := rm.FS.ReadFile(post.TargetPath) + if err != nil { + return fmt.Errorf("read target %s: %w", sanitizePath(post.TargetPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + h := sha256.Sum256(data) + actualHash := fmt.Sprintf("sha256:%x", h) + if actualHash != post.SHA256 { + return fmt.Errorf("target drift: content mismatch for %s (expected %s, got %s)", sanitizePath(post.TargetPath, rm.HomeDir), post.SHA256, actualHash) + } + return nil +} + +func (rm *recoveryManager) verifyPreSnapshot(pre targetPreState) error { + if !pre.Existed { + return nil + } + repoPrePath := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName(pre.RelPath)) + preData, err := rm.StorageFS.ReadFile(repoPrePath) + if err != nil { + return fmt.Errorf("read pre-snapshot for %s: %w", sanitizePath(pre.RelPath, rm.HomeDir), sanitizeError(err, rm.HomeDir)) + } + preH := sha256.Sum256(preData) + actualPreHash := fmt.Sprintf("sha256:%x", preH) + if actualPreHash != pre.SHA256 { + return fmt.Errorf("pre-snapshot checksum mismatch for %s (tampered or corrupted)", sanitizePath(pre.RelPath, rm.HomeDir)) + } + return nil +} + +func (rm *recoveryManager) validatePreMapCorrespondence() error { + for preKey, pre := range rm.Meta.TargetPreMap { + cleanPre := paths.CanonicalPath(pre.TargetPath) + if cleanPre != preKey { + return fmt.Errorf("target correspondence mismatch for %s: pre target path %s does not match key", sanitizePath(preKey, rm.HomeDir), sanitizePath(pre.TargetPath, rm.HomeDir)) + } + if _, ok := rm.Meta.TargetPostMap[preKey]; !ok { + return fmt.Errorf("target correspondence mismatch: missing post-state for target %s", sanitizePath(pre.TargetPath, rm.HomeDir)) + } + } + return nil +} + +func (rm *recoveryManager) validateDriftPrerequisites() error { + if rm.HomeDir == "" { + return fmt.Errorf("home directory cannot be empty") + } + if rm.FS == nil { + return fmt.Errorf("file system cannot be nil") + } + if rm.StorageFS == nil { + rm.StorageFS = &OSFileSystem{} + } + if rm.Meta.Status != statusApplied { + return fmt.Errorf("recovery point status is %s, expected applied", rm.Meta.Status) + } + if len(rm.Meta.TargetPostMap) == 0 { + return fmt.Errorf("no post-restore targets recorded") + } + if len(rm.Meta.TargetPostMap) != len(rm.Meta.TargetPreMap) { + return fmt.Errorf("target correspondence mismatch: post map has %d entries, pre map has %d", len(rm.Meta.TargetPostMap), len(rm.Meta.TargetPreMap)) + } + return nil +} + +func (rm *recoveryManager) initPointAndRepoDirs() { + if rm.PointDir != "" || rm.PointID == "" { + return + } + recoveryBase := rm.RecoveryDir + if recoveryBase == "" { + recoveryBase = filepath.Join(rm.HomeDir, ".bak", "recovery") + } + rm.PointDir = filepath.Join(recoveryBase, rm.PointID) + rm.RepoDir = filepath.Join(rm.PointDir, "repo") +} + +func (rm *recoveryManager) verifyTargetDrift(k string) error { + post := rm.Meta.TargetPostMap[k] + if err := validateNoSymlinkAncestors(rm.FS, rm.HomeDir, post.TargetPath); err != nil { + return fmt.Errorf("validate target %s: %w", sanitizePath(post.TargetPath, rm.HomeDir), err) + } + pre, err := rm.validateTargetCorrespondence(k, post) + if err != nil { + return err + } + if err := rm.verifyLiveTarget(post); err != nil { + return err + } + return rm.verifyPreSnapshot(pre) +} + +// CheckUndoDrift verifies every TargetPostMap entry against the live filesystem BEFORE +// any write is performed: symlink-ancestor validation, absence/existence match, regular-file +// non-symlink, mode Perm equality (non-Windows), SHA-256 content equality, and pre-snapshot +// presence + hash verification. Any mismatch aborts with an error and zero writes. +func (rm *recoveryManager) CheckUndoDrift() error { + if err := rm.validateDriftPrerequisites(); err != nil { + return err + } + rm.initPointAndRepoDirs() + + targetKeys := make([]string, 0, len(rm.Meta.TargetPostMap)) + for k := range rm.Meta.TargetPostMap { + targetKeys = append(targetKeys, k) + } + sort.Strings(targetKeys) + + for _, k := range targetKeys { + if err := rm.verifyTargetDrift(k); err != nil { + return err + } + } + + return rm.validatePreMapCorrespondence() +} + +func (rm *recoveryManager) persistFailedMetadata(outcome *rollbackOutcome) { + rm.Meta.Status = statusFailed + metaBytes, err := json.MarshalIndent(rm.Meta, "", " ") + if err != nil { + outcome.Errors = append(outcome.Errors, fmt.Errorf("marshal recovery meta: %w", err)) + return + } + metaFilePath := filepath.Join(rm.RepoDir, "recovery-meta.json") + if writeErr := rm.StorageFS.WriteFile(metaFilePath, metaBytes, 0600); writeErr != nil { + outcome.Errors = append(outcome.Errors, fmt.Errorf("write failed recovery metadata: %w", sanitizeError(writeErr, rm.HomeDir))) + } +} + +func (rm *recoveryManager) commitOrPersistUndoOutcome(outcome *rollbackOutcome) { + if len(outcome.Unresolved) > 0 { + rm.Meta.Status = statusFailed + } else { + rm.Meta.Status = statusUndone + } + + gitRepo, err := git.OpenRepo(rm.RepoDir) + if err != nil { + outcome.Errors = append(outcome.Errors, fmt.Errorf("open recovery git repo: %w", sanitizeError(err, rm.HomeDir))) + rm.persistFailedMetadata(outcome) + return + } + + commitMsg := "undo target snapshot" + if rm.Meta.Status == statusFailed { + commitMsg = "partial undo target failure" + } + if commitErr := rm.commitMetadataAndSnapshot(gitRepo, commitMsg); commitErr != nil { + outcome.Errors = append(outcome.Errors, fmt.Errorf("commit undo metadata: %w", commitErr)) + rm.persistFailedMetadata(outcome) + } +} + +// UndoTargets projects TargetPreMap back to live files (bytes, mode including zero, absence +// via single-file remove refusing directories), hash-verifies pre snapshots, updates metadata +// Status to "undone" and commits the metadata update in the recovery repo. On partial failure, +// Status is set to "failed", Reverted/Unresolved accounting is preserved, and an error is returned. +func (rm *recoveryManager) UndoTargets() (rollbackOutcome, error) { + outcome := rollbackOutcome{ + PointID: rm.PointID, + } + + rm.initPointAndRepoDirs() + if rm.StorageFS == nil { + rm.StorageFS = &OSFileSystem{} + } + + targets := make([]string, 0, len(rm.Meta.TargetPreMap)) + for target := range rm.Meta.TargetPreMap { + targets = append(targets, target) + } + sort.Strings(targets) + + for _, target := range targets { + pre, ok := rm.Meta.TargetPreMap[target] + if !ok { + outcome.Unresolved = append(outcome.Unresolved, target) + outcome.Errors = append(outcome.Errors, fmt.Errorf("target correspondence mismatch: no pre-state for %s", sanitizePath(target, rm.HomeDir))) + continue + } + cleanPreTarget := paths.CanonicalPath(pre.TargetPath) + if cleanPreTarget != target { + outcome.Unresolved = append(outcome.Unresolved, pre.TargetPath) + outcome.Errors = append(outcome.Errors, fmt.Errorf("target correspondence mismatch for %s: pre path %s", sanitizePath(target, rm.HomeDir), sanitizePath(pre.TargetPath, rm.HomeDir))) + continue + } + post, ok := rm.Meta.TargetPostMap[target] + if !ok || paths.CanonicalPath(post.TargetPath) != target { + outcome.Unresolved = append(outcome.Unresolved, pre.TargetPath) + outcome.Errors = append(outcome.Errors, fmt.Errorf("target correspondence mismatch: no matching post-state for %s", sanitizePath(target, rm.HomeDir))) + continue + } + + if err := rm.rollbackTarget(pre); err != nil { + outcome.Unresolved = append(outcome.Unresolved, pre.TargetPath) + outcome.Errors = append(outcome.Errors, err) + } else { + outcome.Reverted = append(outcome.Reverted, pre.TargetPath) + } + } + sort.Strings(outcome.Reverted) + sort.Strings(outcome.Unresolved) + + rm.commitOrPersistUndoOutcome(&outcome) + + if len(outcome.Unresolved) > 0 || len(outcome.Errors) > 0 { + errMsgs := make([]string, 0, len(outcome.Errors)) + for _, e := range outcome.Errors { + errMsgs = append(errMsgs, e.Error()) + } + return outcome, fmt.Errorf("undo targets failed: %s", strings.Join(errMsgs, "; ")) + } + + return outcome, nil +} diff --git a/internal/actions/recovery_test.go b/internal/actions/recovery_test.go new file mode 100644 index 0000000..afb93c9 --- /dev/null +++ b/internal/actions/recovery_test.go @@ -0,0 +1,1932 @@ +package actions + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/danielxxomg/bak-cli/internal/git" + "github.com/danielxxomg/bak-cli/internal/manifest" + "github.com/danielxxomg/bak-cli/internal/paths" + restorepkg "github.com/danielxxomg/bak-cli/internal/restore" +) + +// setupRecoveryTestEnv prepares home and backupDir with manifest and items. +func setupRecoveryTestEnv(t *testing.T, backupID string, files map[string]string) (string, string) { + t.Helper() + home := t.TempDir() + bakDir := filepath.Join(home, ".bak") + backupsDir := filepath.Join(bakDir, "backups") + backupDir := filepath.Join(backupsDir, backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + + m := manifest.New(backupID, "linux", "testhost", "0.4.0", "quick", []string{"config"}) + m.Version = "0.4.0" + items := make([]manifest.Item, 0, len(files)) + + keys := make([]string, 0, len(files)) + for k := range files { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, relName := range keys { + content := files[relName] + backedPath := filepath.Join(adapterDir, relName) + if err := os.MkdirAll(filepath.Dir(backedPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(backedPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + h := sha256.Sum256([]byte(content)) + items = append(items, manifest.Item{ + Category: "config", + SourcePath: fmt.Sprintf("~/.config/bak/%s", relName), + BackupPath: fmt.Sprintf("test-adapter/%s", relName), + Hash: fmt.Sprintf("sha256:%x", h), + Size: int64(len(content)), + Mode: 0644, + }) + } + + m.AddAdapter("test-adapter", "", "~/.config/bak", items) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + return home, backupDir +} + +func TestRecoveryManager_DryRunNoSideEffects(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-dryrun", map[string]string{ + "a.txt": "content-a\n", + }) + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + DryRun: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + if err := action.Run(); err != nil { + t.Fatalf("dry run failed: %v", err) + } + + // Assert no recovery directory was created. + recDir := filepath.Join(home, ".bak", "recovery") + if _, err := os.Stat(recDir); err == nil { + t.Errorf("recovery directory should not exist on dry-run, found %s", recDir) + } +} + +func TestRecoveryManager_UserCancelNoSideEffects(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-cancel", map[string]string{ + "a.txt": "content-a\n", + }) + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + Force: false, + Stdin: strings.NewReader("n\n"), + Stdout: io.Discard, + Stderr: io.Discard, + } + + if err := action.Run(); err != nil { + t.Fatalf("run failed: %v", err) + } + + recDir := filepath.Join(home, ".bak", "recovery") + if _, err := os.Stat(recDir); err == nil { + t.Errorf("recovery directory should not exist on cancel, found %s", recDir) + } +} + +func TestRecoveryManager_UnchangedNoSideEffects(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-unchanged", map[string]string{ + "a.txt": "same-content\n", + }) + + // Create target with exact same content. + targetPath := filepath.Join(home, ".config", "bak", "a.txt") + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetPath, []byte("same-content\n"), 0644); err != nil { + t.Fatal(err) + } + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + if err := action.Run(); err != nil { + t.Fatalf("run failed: %v", err) + } + + recDir := filepath.Join(home, ".bak", "recovery") + if _, err := os.Stat(recDir); err == nil { + t.Errorf("recovery directory should not exist when all diffs are unchanged, found %s", recDir) + } +} + +func TestRecoveryManager_RollbackRemovesNewlyCreatedTargets(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-newtargets", map[string]string{ + "f1.txt": "new1\n", + "f2.txt": "new2\n", + }) + + targetDir := filepath.Join(home, ".config", "bak") + t1 := filepath.Join(targetDir, "f1.txt") + t2 := filepath.Join(targetDir, "f2.txt") + + baseFS := newHomeFS(home) + failingFS := &partialCopyFailingFS{ + FileSystem: baseFS, + failOnDst: t2, + failErr: fmt.Errorf("disk full writing t2"), + } + + action := &RestoreAction{ + FS: failingFS, + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } + + // Since neither t1 nor t2 existed before restore, rollback must remove them. + if _, err := os.Stat(t1); err == nil { + t.Errorf("new target t1 should be removed by rollback, but still exists") + } + if _, err := os.Stat(t2); err == nil { + t.Errorf("new target t2 should be removed by rollback, but still exists") + } +} + +func TestRecoveryManager_SymlinkTargetRefused(t *testing.T) { //nolint:paralleltest // shared state + if isWindows() { + t.Skip("skipping symlink test on Windows") + } + home, backupDir := setupRecoveryTestEnv(t, "20260101-symlink", map[string]string{ + "link.txt": "linkcontent\n", + }) + + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + outsideFile := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outsideFile, []byte("outside"), 0644); err != nil { + t.Fatal(err) + } + symlinkPath := filepath.Join(targetDir, "link.txt") + if err := os.Symlink(outsideFile, symlinkPath); err != nil { + t.Fatal(err) + } + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error when target is a symlink, got nil") + } + if !strings.Contains(err.Error(), "symlink") { + t.Errorf("error %q should mention symlink", err.Error()) + } +} + +func TestRecoveryManager_SuccessfulRestoreGitEvidence(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-success", map[string]string{ + "config.json": "updated-config\n", + }) + + targetPath := filepath.Join(home, ".config", "bak", "config.json") + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(targetPath, []byte("old-config\n"), 0644); err != nil { + t.Fatal(err) + } + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + if err := action.Run(); err != nil { + t.Fatalf("expected successful restore, got: %v", err) + } + + recBase := filepath.Join(home, ".bak", "recovery") + entries, err := os.ReadDir(recBase) + if err != nil || len(entries) == 0 { + t.Fatalf("expected recovery point directory, got err: %v, entries: %d", err, len(entries)) + } + + pointDir := filepath.Join(recBase, entries[0].Name()) + repoDir := filepath.Join(pointDir, "repo") + if !git.IsRepo(repoDir) { + t.Errorf("expected valid git repo at %s", repoDir) + } + + // Verify post-restore snapshot commit exists. + repo, err := git.OpenRepo(repoDir) + if err != nil { + t.Fatalf("open repo: %v", err) + } + head, err := repo.Head() + if err != nil { + t.Fatalf("get head: %v", err) + } + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("get commit: %v", err) + } + if commit.Message != "post-restore snapshot" { + t.Errorf("commit message = %q, want 'post-restore snapshot'", commit.Message) + } +} + +// targetStatFailingFS wraps FileSystem and fails Lstat on a target. +type targetStatFailingFS struct { + FileSystem + failPath string +} + +var _ FileSystem = (*targetStatFailingFS)(nil) + +func (f *targetStatFailingFS) Lstat(path string) (os.FileInfo, error) { + if path == f.failPath { + return nil, os.ErrPermission + } + return f.FileSystem.Lstat(path) +} + +func TestRecoveryManager_PrepareFailureAbortsBeforeWrites(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-prep-fail", map[string]string{ + "target.txt": "new-data\n", + }) + + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + targetFile := filepath.Join(targetDir, "target.txt") + if err := os.WriteFile(targetFile, []byte("original-data\n"), 0644); err != nil { + t.Fatal(err) + } + + baseFS := newHomeFS(home) + failingFS := &targetStatFailingFS{ + FileSystem: baseFS, + failPath: targetFile, + } + + action := &RestoreAction{ + FS: failingFS, + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected run to fail when recovery preparation fails, got nil") + } + + // Verify original file remained intact and was never mutated. + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(got) != "original-data\n" { + t.Errorf("target file was modified despite prepare failure: %q", string(got)) + } +} + +// removeFailingFS fails RemoveAll on rollback to test unresolved reporting. +type removeFailingFS struct { + FileSystem + failRemovePath string +} + +var _ FileSystem = (*removeFailingFS)(nil) + +func (r *removeFailingFS) Remove(path string) error { + if path == r.failRemovePath { + return fmt.Errorf("injected remove error on %s", path) + } + return r.FileSystem.Remove(path) +} + +func (r *removeFailingFS) RemoveAll(path string) error { + if path == r.failRemovePath { + return fmt.Errorf("injected remove error on %s", path) + } + return r.FileSystem.RemoveAll(path) +} + +func TestRecoveryManager_RollbackFailureReported(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-unresolved", map[string]string{ + "new1.txt": "data1\n", + "new2.txt": "data2\n", + }) + + targetDir := filepath.Join(home, ".config", "bak") + t2 := filepath.Join(targetDir, "new2.txt") + + baseFS := newHomeFS(home) + // Fail copy on t2, and fail remove on t1 during rollback. + partialFS := &partialCopyFailingFS{ + FileSystem: baseFS, + failOnDst: t2, + failErr: fmt.Errorf("injected copy failure on t2"), + } + remFS := &removeFailingFS{ + FileSystem: partialFS, + failRemovePath: t2, + } + + action := &RestoreAction{ + FS: remFS, + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "rollback unresolved") { + t.Errorf("error %q should mention unresolved rollback", err.Error()) + } +} + +func TestRecoveryManager_NoContaminationOfBackupArchives(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-archive-test", map[string]string{ + "item.txt": "content\n", + }) + + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + if err := action.Run(); err != nil { + t.Fatalf("restore run: %v", err) + } + + // Verify backup directory contains ONLY its original files and manifest, + // with NO recovery artifacts contaminated into ~/.bak/backups/. + entries, err := os.ReadDir(backupDir) + if err != nil { + t.Fatalf("read backup dir: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), "rec-") || e.Name() == "recovery" || e.Name() == "repo" { + t.Errorf("backup archive was contaminated with recovery item: %s", e.Name()) + } + } +} + +func TestRecoveryManager_ManualRollbackAfterPostRecord_RestoresOriginalBytes(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + targetPath := filepath.Join(home, "file.txt") + if err := os.WriteFile(targetPath, []byte("original-v1"), 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Mutate target to restored bytes + if err := os.WriteFile(targetPath, []byte("restored-v2"), 0644); err != nil { + t.Fatal(err) + } + + // Record post state (simulates post-recording having captured post-bytes) + if err := rm.RecordPostState(); err != nil { + t.Fatalf("record post state: %v", err) + } + + // Simulate post-state failure triggering rollback + outcome := rm.Rollback([]string{targetPath}) + if len(outcome.Errors) > 0 { + t.Fatalf("rollback error: %v", outcome.Errors) + } + + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "original-v1" { + t.Fatalf("expected target to be rolled back to 'original-v1', got %q", string(got)) + } +} + +func TestRecoveryManager_TargetNamedRecoveryMeta_NotCollided(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + targetPath := filepath.Join(home, "recovery-meta.json") + if err := os.WriteFile(targetPath, []byte("user-meta-content"), 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Mutate target + if err := os.WriteFile(targetPath, []byte("tampered-content"), 0644); err != nil { + t.Fatal(err) + } + + outcome := rm.Rollback([]string{targetPath}) + if len(outcome.Errors) > 0 { + t.Fatalf("rollback error: %v", outcome.Errors) + } + + got, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + if string(got) != "user-meta-content" { + t.Fatalf("target was corrupted by metadata collision: got %q, want 'user-meta-content'", string(got)) + } +} + +func TestRecoveryManager_StorageSymlinkAncestor_Rejected(t *testing.T) { //nolint:paralleltest // shared state + if isWindows() { + t.Skip("symlinks not supported on Windows") + } + home := t.TempDir() + outsideDir := t.TempDir() + bakSymlink := filepath.Join(home, ".bak") + if err := os.Symlink(outsideDir, bakSymlink); err != nil { + t.Fatal(err) + } + + targetPath := filepath.Join(home, "file.txt") + if err := os.WriteFile(targetPath, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetPath}, + } + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to fail when recovery storage has symlink ancestor, got nil") + } + + // Ensure outside directory was not modified + entries, err := os.ReadDir(outsideDir) + if err != nil { + t.Fatal(err) + } + if len(entries) > 0 { + t.Fatalf("outside directory was modified despite symlink ancestor: %d entries", len(entries)) + } +} + +func TestRecoveryManager_RollbackDeletion_RefusesDirectoryReplacement(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + targetPath := filepath.Join(home, "newfile.txt") + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Unexpected directory created at targetPath with user file + if err := os.MkdirAll(targetPath, 0755); err != nil { + t.Fatal(err) + } + userFile := filepath.Join(targetPath, "user.txt") + if err := os.WriteFile(userFile, []byte("precious user data"), 0644); err != nil { + t.Fatal(err) + } + + outcome := rm.Rollback([]string{targetPath}) + // Should refuse to remove directory and report unresolved + if len(outcome.Unresolved) == 0 { + t.Fatalf("expected targetPath to be unresolved, got reverted: %v", outcome.Reverted) + } + + // Check user file was NOT deleted! + if _, err := os.Stat(userFile); err != nil { + t.Fatalf("user file inside directory was destroyed: %v", err) + } +} + +func TestRecoveryManager_Mode0000_Restored(t *testing.T) { //nolint:paralleltest // shared state + mockFS := &MockFileSystem{ + HomeDir: "/home/test", + Files: make(map[string][]byte), + ChmodCalls: make(map[string]os.FileMode), + } + rm := &recoveryManager{ + FS: mockFS, + StorageFS: mockFS, + HomeDir: "/home/test", + PointID: "rec-test", + RepoDir: "/home/test/.bak/recovery/rec-test/repo", + PointDir: "/home/test/.bak/recovery/rec-test", + } + + data := []byte("data") + h := sha256.Sum256(data) + pre := targetPreState{ + TargetPath: "/home/test/mode0000.txt", + RelPath: "mode0000.txt", + Existed: true, + Mode: 0000, + SHA256: fmt.Sprintf("sha256:%x", h), + } + // Place mock data in repo pre snapshot + mockFS.Files[filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName(pre.RelPath))] = data + + err := rm.rollbackTarget(pre) + if err != nil { + t.Fatalf("rollback: %v", err) + } + + mode, called := mockFS.ChmodCalls["/home/test/mode0000.txt"] + if !called { + t.Fatal("expected Chmod to be called for mode 0000, but it was skipped") + } + if mode != 0000 { + t.Fatalf("expected mode 0000, got %v", mode) + } +} + +func TestRecoveryManager_SnapshotTampered_RefusesRollback(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + targetPath := filepath.Join(home, "file.txt") + if err := os.WriteFile(targetPath, []byte("original"), 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Tamper snapshot file directly + snapFile := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file.txt")) + if err := os.WriteFile(snapFile, []byte("tampered-snapshot"), 0600); err != nil { + t.Fatal(err) + } + + outcome := rm.Rollback([]string{targetPath}) + if len(outcome.Unresolved) == 0 { + t.Fatalf("expected rollback to refuse tampered snapshot and mark unresolved, got reverted: %v", outcome.Reverted) + } +} + +func TestRecoveryManager_TargetOverlapCaseInsensitive_Rejected(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + // Target overlaps with case alias of recovery directory + overlapTarget := filepath.Join(home, ".BAK", "RECOVERY", "rogue.txt") + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: overlapTarget}, + } + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to reject case-insensitive recovery storage overlap, got nil") + } + if !strings.Contains(err.Error(), "overlaps recovery storage") { + t.Errorf("error %q should mention overlaps recovery storage", err.Error()) + } +} + +func TestRecoveryManager_RecoveryDirOutsideHome_Rejected(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + outsideDir := t.TempDir() + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + RecoveryDir: outsideDir, + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: filepath.Join(home, "file.txt")}, + } + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to reject recovery directory outside home, got nil") + } + if !strings.Contains(err.Error(), "escapes home directory") { + t.Errorf("error %q should mention escapes home directory", err.Error()) + } +} + +func TestRecoveryManager_ErrorPrivacy_NoUsernames(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + outsideFile := filepath.Join(t.TempDir(), "secret.txt") + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: outsideFile}, + } + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected error, got nil") + } + + // Error message should NOT leak the raw home directory path + errMsg := err.Error() + if strings.Contains(errMsg, home) { + t.Errorf("error message %q leaked home path %q", errMsg, home) + } +} + +func TestRecoveryManager_ThirdTargetUntouchedOnFailure(t *testing.T) { //nolint:paralleltest // shared state + home, backupDir := setupRecoveryTestEnv(t, "20260101-three-targets", map[string]string{ + "t1.txt": "t1-new\n", + "t2.txt": "t2-new\n", + "t3.txt": "t3-new\n", + }) + + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + t1 := filepath.Join(targetDir, "t1.txt") + t2 := filepath.Join(targetDir, "t2.txt") + t3 := filepath.Join(targetDir, "t3.txt") + + if err := os.WriteFile(t1, []byte("t1-orig\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(t2, []byte("t2-orig\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(t3, []byte("t3-orig\n"), 0644); err != nil { + t.Fatal(err) + } + + baseFS := newHomeFS(home) + failingFS := &partialCopyFailingFS{ + FileSystem: baseFS, + failOnDst: t2, + failErr: fmt.Errorf("injected failure on t2"), + } + + action := &RestoreAction{ + FS: failingFS, + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } + + // t3 was never attempted; its content must be untouched original content + got3, err := os.ReadFile(t3) + if err != nil { + t.Fatal(err) + } + if string(got3) != "t3-orig\n" { + t.Fatalf("t3 was modified: got %q, want 't3-orig\\n'", string(got3)) + } +} + +func TestRecoveryManager_GitPayloadDurability_NestedGitAndGitignoreHandled(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + fs := newHomeFS(home) + + gitConfigPath := filepath.Join(home, ".git", "config") + gitignorePath := filepath.Join(home, ".gitignore") + ignoredFilePath := filepath.Join(home, "ignored.txt") + + if err := os.MkdirAll(filepath.Dir(gitConfigPath), 0755); err != nil { + t.Fatal(err) + } + gitConfigBytes := []byte("[core]\n\trepositoryformatversion = 0\n") + gitignoreBytes := []byte("ignored.txt\n*.txt\n") + ignoredFileBytes := []byte("payload-must-be-durable\n") + + if err := os.WriteFile(gitConfigPath, gitConfigBytes, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gitignorePath, gitignoreBytes, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ignoredFilePath, ignoredFileBytes, 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: fs, + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: gitConfigPath}, + {Status: restorepkg.DiffModified, TargetPath: gitignorePath}, + {Status: restorepkg.DiffModified, TargetPath: ignoredFilePath}, + } + + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare failed: %v", err) + } + + // Open the recovery repository and inspect the pre-commit tree. + repo, err := git.OpenRepo(rm.RepoDir) + if err != nil { + t.Fatalf("open recovery git repo: %v", err) + } + head, err := repo.Head() + if err != nil { + t.Fatalf("head: %v", err) + } + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("commit: %v", err) + } + tree, err := commit.Tree() + if err != nil { + t.Fatalf("tree: %v", err) + } + + // Calculate opaque payload names derived from relative path hash + relGitConfig, err := filepath.Rel(home, gitConfigPath) + if err != nil { + t.Fatal(err) + } + relGitignore, err := filepath.Rel(home, gitignorePath) + if err != nil { + t.Fatal(err) + } + relIgnoredFile, err := filepath.Rel(home, ignoredFilePath) + if err != nil { + t.Fatal(err) + } + + hGitConfig := sha256.Sum256([]byte(path.Clean(strings.ReplaceAll(relGitConfig, "\\", "/")))) + hGitignore := sha256.Sum256([]byte(path.Clean(strings.ReplaceAll(relGitignore, "\\", "/")))) + hIgnoredFile := sha256.Sum256([]byte(path.Clean(strings.ReplaceAll(relIgnoredFile, "\\", "/")))) + + payloadGitConfig := fmt.Sprintf("%x", hGitConfig) + payloadGitignore := fmt.Sprintf("%x", hGitignore) + payloadIgnoredFile := fmt.Sprintf("%x", hIgnoredFile) + + // Assert the files are present in the git commit tree under opaque flat filenames + entryGitConfig, err := tree.FindEntry("snapshots/pre/" + payloadGitConfig) + if err != nil { + t.Fatalf("expected .git/config payload %s to be present in pre-commit tree: %v", payloadGitConfig, err) + } + blobGitConfig, err := repo.BlobObject(entryGitConfig.Hash) + if err != nil { + t.Fatalf("read blob for git config: %v", err) + } + rGitConfig, err := blobGitConfig.Reader() + if err != nil { + t.Fatalf("blob reader: %v", err) + } + readGitConfigBytes, err := io.ReadAll(rGitConfig) + if err != nil { + t.Fatal(err) + } + if err := rGitConfig.Close(); err != nil { + t.Fatal(err) + } + if string(readGitConfigBytes) != string(gitConfigBytes) { + t.Errorf("git config bytes mismatch in tree: got %q, want %q", string(readGitConfigBytes), string(gitConfigBytes)) + } + + entryIgnored, err := tree.FindEntry("snapshots/pre/" + payloadIgnoredFile) + if err != nil { + t.Fatalf("expected ignored file payload %s to be present in pre-commit tree (despite captured .gitignore): %v", payloadIgnoredFile, err) + } + blobIgnored, err := repo.BlobObject(entryIgnored.Hash) + if err != nil { + t.Fatalf("read blob for ignored file: %v", err) + } + rIgnored, err := blobIgnored.Reader() + if err != nil { + t.Fatalf("blob reader: %v", err) + } + readIgnoredBytes, err := io.ReadAll(rIgnored) + if err != nil { + t.Fatal(err) + } + if err := rIgnored.Close(); err != nil { + t.Fatal(err) + } + if string(readIgnoredBytes) != string(ignoredFileBytes) { + t.Errorf("ignored file bytes mismatch in tree: got %q, want %q", string(readIgnoredBytes), string(ignoredFileBytes)) + } + + entryGitignore, err := tree.FindEntry("snapshots/pre/" + payloadGitignore) + if err != nil { + t.Fatalf("expected .gitignore payload %s to be present in pre-commit tree: %v", payloadGitignore, err) + } + _ = entryGitignore + + // Mutate all 3 files to prove rollback restores them accurately + if err := os.WriteFile(gitConfigPath, []byte("mutated-config"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gitignorePath, []byte("mutated-ignore"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ignoredFilePath, []byte("mutated-ignored-file"), 0644); err != nil { + t.Fatal(err) + } + + outcome := rm.Rollback([]string{gitConfigPath, gitignorePath, ignoredFilePath}) + if len(outcome.Errors) > 0 { + t.Fatalf("rollback returned errors: %v", outcome.Errors) + } + if len(outcome.Unresolved) > 0 { + t.Fatalf("rollback left unresolved targets: %v", outcome.Unresolved) + } + + gotConfig, err := os.ReadFile(gitConfigPath) + if err != nil { + t.Fatal(err) + } + if string(gotConfig) != string(gitConfigBytes) { + t.Errorf("rollback failed to restore .git/config: got %q, want %q", string(gotConfig), string(gitConfigBytes)) + } + + gotIgnore, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatal(err) + } + if string(gotIgnore) != string(gitignoreBytes) { + t.Errorf("rollback failed to restore .gitignore: got %q, want %q", string(gotIgnore), string(gitignoreBytes)) + } + + gotIgnored, err := os.ReadFile(ignoredFilePath) + if err != nil { + t.Fatal(err) + } + if string(gotIgnored) != string(ignoredFileBytes) { + t.Errorf("rollback failed to restore ignored file: got %q, want %q", string(gotIgnored), string(ignoredFileBytes)) + } +} + +type pathErrorStorageFS struct { + FileSystem + failOnWrite string +} + +var _ FileSystem = (*pathErrorStorageFS)(nil) + +func (p *pathErrorStorageFS) WriteFile(path string, data []byte, perm os.FileMode) error { + if strings.Contains(path, p.failOnWrite) { + return &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + return p.FileSystem.WriteFile(path, data, perm) +} + +func TestRecoveryManager_StorageErrorPrivacy_WrappedPathError(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + fs := newHomeFS(home) + + targetFile := filepath.Join(home, "target.txt") + if err := os.WriteFile(targetFile, []byte("target-content"), 0644); err != nil { + t.Fatal(err) + } + + storageFS := &pathErrorStorageFS{ + FileSystem: &OSFileSystem{}, + failOnWrite: "snapshots", + } + + rm := &recoveryManager{ + FS: fs, + StorageFS: storageFS, + HomeDir: home, + BackupID: "test-backup", + PointID: "rec-storage-privacy", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetFile}, + } + + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to fail on snapshot storage write error, got nil") + } + + // Must NOT leak raw home path in error message + if strings.Contains(err.Error(), home) { + t.Errorf("error %q leaks absolute home path %q", err.Error(), home) + } + + // Must preserve errors.Is through wrapped errors + if !errors.Is(err, os.ErrPermission) { + t.Errorf("expected errors.Is(err, os.ErrPermission) to be true, got error: %v", err) + } +} + +type chmodFailStorageFS struct { + FileSystem +} + +var _ FileSystem = (*chmodFailStorageFS)(nil) + +func (c *chmodFailStorageFS) Chmod(path string, perm os.FileMode) error { + return fmt.Errorf("injected private chmod failure on %s", path) +} + +func TestRecoveryManager_PrivateChmodFailure_NoTargetWrites(t *testing.T) { //nolint:paralleltest // shared state + if isWindows() { + t.Skip("skipping chmod failure test on Windows") + } + home := t.TempDir() + fs := newHomeFS(home) + + targetFile := filepath.Join(home, "target.txt") + origContent := []byte("original-unmodified-content") + if err := os.WriteFile(targetFile, origContent, 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: fs, + StorageFS: &chmodFailStorageFS{FileSystem: &OSFileSystem{}}, + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetFile}, + } + + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to fail on private chmod failure, got nil") + } + + // Target file must remain untouched + got, err := os.ReadFile(targetFile) + if err != nil { + t.Fatal(err) + } + if string(got) != string(origContent) { + t.Errorf("target file was modified: got %q, want %q", string(got), string(origContent)) + } +} + +type lstatErrFS struct { + FileSystem + failPath string +} + +var _ FileSystem = (*lstatErrFS)(nil) + +func (l *lstatErrFS) Lstat(path string) (os.FileInfo, error) { + if path == l.failPath { + return nil, fmt.Errorf("injected IO error on lstat") + } + return l.FileSystem.Lstat(path) +} + +func TestRecoveryManager_RollbackLstatIO_MarkedUnresolved(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + targetPath := filepath.Join(home, "newfile.txt") + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Set FS to return non-ErrNotExist I/O error on targetPath + rm.FS = &lstatErrFS{ + FileSystem: rm.FS, + failPath: targetPath, + } + + outcome := rm.Rollback([]string{targetPath}) + if len(outcome.Unresolved) == 0 { + t.Fatalf("expected targetPath to be unresolved on lstat IO error, got reverted: %v", outcome.Reverted) + } + if len(outcome.Errors) == 0 { + t.Fatal("expected rollback errors, got none") + } +} + +func TestRecoveryManager_DuplicateCaseAlias_Refused(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + p1 := filepath.Join(home, "config.txt") + p2 := filepath.Join(home, "CONFIG.TXT") + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: p1}, + {Status: restorepkg.DiffNew, TargetPath: p2}, + } + + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected prepare to refuse duplicate case-alias targets, got nil") + } + if !strings.Contains(err.Error(), "duplicate target path") { + t.Errorf("error %q should mention duplicate target path", err.Error()) + } +} + +func TestRecoveryManager_UnsafeRecoveryDirOverride_LeavesExternalDirUntouched(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + outsideDir := t.TempDir() + canaryFile := filepath.Join(outsideDir, "canary.txt") + if err := os.WriteFile(canaryFile, []byte("canary"), 0644); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + RecoveryDir: outsideDir, + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffNew, TargetPath: filepath.Join(home, "f.txt")}, + } + + err := rm.Prepare(diffs) + if err == nil { + t.Fatal("expected unsafe recovery dir override to fail, got nil") + } + + // Canary file must still exist and outsideDir must not have repo created in it + canaryBytes, err := os.ReadFile(canaryFile) + if err != nil { + t.Fatalf("canary file missing: %v", err) + } + if string(canaryBytes) != "canary" { + t.Errorf("canary file was corrupted: %q", string(canaryBytes)) + } + entries, _ := os.ReadDir(outsideDir) + if len(entries) != 1 { + t.Errorf("outside directory was modified: %d entries", len(entries)) + } +} + +func TestRecoveryManager_RealChangedModeRestoration(t *testing.T) { //nolint:paralleltest // shared state + if isWindows() { + t.Skip("skipping mode restoration test on Windows") + } + home := t.TempDir() + targetPath := filepath.Join(home, "script.sh") + if err := os.WriteFile(targetPath, []byte("#!/bin/sh\necho hi\n"), 0755); err != nil { + t.Fatal(err) + } + + rm := &recoveryManager{ + FS: newHomeFS(home), + HomeDir: home, + BackupID: "test-backup", + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: targetPath}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Mutate both content and mode (e.g. 0644) + if err := os.WriteFile(targetPath, []byte("#!/bin/sh\necho mutated\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(targetPath, 0644); err != nil { + t.Fatal(err) + } + + outcome := rm.Rollback([]string{targetPath}) + if len(outcome.Errors) > 0 { + t.Fatalf("rollback error: %v", outcome.Errors) + } + + fi, err := os.Stat(targetPath) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0755 { + t.Errorf("target mode was not restored to 0755: got %v", fi.Mode().Perm()) + } +} + +func TestRecovery_FindLatestAppliedPoint(t *testing.T) { + t.Parallel() + + writeMeta := func(recBase, pointID string, meta recoveryMetadata) { + pDir := filepath.Join(recBase, pointID, "repo") + if err := os.MkdirAll(pDir, 0755); err != nil { + t.Fatal(err) + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pDir, "recovery-meta.json"), data, 0644); err != nil { + t.Fatal(err) + } + } + + tests := []struct { + name string + setup func(recBase string) + overrideBase func(home string) string + wantErrIs error + wantPointID string + expectErrFrag string + }{ + { + name: "none when directory does not exist", + setup: func(string) {}, + overrideBase: func(home string) string { + return filepath.Join(home, ".bak", "nonexistent") + }, + wantErrIs: errNoAppliedPoint, + }, + { + name: "none when directory is empty", + setup: func(recBase string) { + _ = os.MkdirAll(recBase, 0755) + }, + wantErrIs: errNoAppliedPoint, + }, + { + name: "skip non-applied points", + setup: func(recBase string) { + t0 := time.Now().UTC() + writeMeta(recBase, "p-prepared", recoveryMetadata{PointID: "p-prepared", Status: "prepared", CreatedAt: t0}) + writeMeta(recBase, "p-failed", recoveryMetadata{PointID: "p-failed", Status: "failed", CreatedAt: t0.Add(time.Minute)}) + writeMeta(recBase, "p-rolledback", recoveryMetadata{PointID: "p-rolledback", Status: "rolled_back", CreatedAt: t0.Add(2 * time.Minute)}) + writeMeta(recBase, "p-undone", recoveryMetadata{PointID: "p-undone", Status: "undone", CreatedAt: t0.Add(3 * time.Minute)}) + }, + wantErrIs: errNoAppliedPoint, + }, + { + name: "skip corrupt points and find valid applied point", + setup: func(recBase string) { + t0 := time.Now().UTC() + // Corrupt: invalid JSON + corruptDir := filepath.Join(recBase, "p-corrupt", "repo") + _ = os.MkdirAll(corruptDir, 0755) + _ = os.WriteFile(filepath.Join(corruptDir, "recovery-meta.json"), []byte("{bad json"), 0644) + + // Corrupt: empty directory without meta + _ = os.MkdirAll(filepath.Join(recBase, "p-nometa"), 0755) + + // Regular file instead of directory + _ = os.WriteFile(filepath.Join(recBase, "p-file"), []byte("not a dir"), 0644) + + // Valid applied point + writeMeta(recBase, "p-valid", recoveryMetadata{PointID: "p-valid", Status: "applied", CreatedAt: t0}) + }, + wantPointID: "p-valid", + }, + { + name: "sort multiple applied points by created_at desc", + setup: func(recBase string) { + t0 := time.Now().UTC() + writeMeta(recBase, "p-old", recoveryMetadata{PointID: "p-old", Status: "applied", CreatedAt: t0.Add(-10 * time.Minute)}) + writeMeta(recBase, "p-newest", recoveryMetadata{PointID: "p-newest", Status: "applied", CreatedAt: t0}) + writeMeta(recBase, "p-mid", recoveryMetadata{PointID: "p-mid", Status: "applied", CreatedAt: t0.Add(-5 * time.Minute)}) + }, + wantPointID: "p-newest", + }, + { + name: "tiebreak on identical created_at by point_id desc", + setup: func(recBase string) { + t0 := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + writeMeta(recBase, "p-alpha", recoveryMetadata{PointID: "p-alpha", Status: "applied", CreatedAt: t0}) + writeMeta(recBase, "p-omega", recoveryMetadata{PointID: "p-omega", Status: "applied", CreatedAt: t0}) + writeMeta(recBase, "p-beta", recoveryMetadata{PointID: "p-beta", Status: "applied", CreatedAt: t0}) + }, + wantPointID: "p-omega", + }, + { + name: "recovery base escaping home directory rejected", + setup: func(string) {}, + overrideBase: func(home string) string { + return t.TempDir() + }, + expectErrFrag: "escapes home directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + home := t.TempDir() + storageFS := newHomeFS(home) + recBase := filepath.Join(home, ".bak", "recovery") + if tt.overrideBase != nil { + recBase = tt.overrideBase(home) + } + tt.setup(recBase) + + meta, err := findLatestAppliedPoint(storageFS, recBase, home) + if tt.wantErrIs != nil { + if !errors.Is(err, tt.wantErrIs) { + t.Fatalf("expected error %v, got %v", tt.wantErrIs, err) + } + return + } + if tt.expectErrFrag != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectErrFrag) { + t.Fatalf("expected error containing %q, got %v", tt.expectErrFrag, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if meta == nil || meta.PointID != tt.wantPointID { + t.Fatalf("expected PointID %q, got %+v", tt.wantPointID, meta) + } + }) + } +} + +func setupUndoTestScenario(t *testing.T) (*recoveryManager, string, string, string) { + t.Helper() + home := t.TempDir() + fs := newHomeFS(home) + recDir := filepath.Join(home, ".bak", "recovery") + + // Target 1: existed prior to restore with "orig1" + t1 := filepath.Join(home, "file1.txt") + if err := os.WriteFile(t1, []byte("orig1\n"), 0644); err != nil { + t.Fatal(err) + } + + // Target 2: will be a new file created by restore + t2 := filepath.Join(home, "file2.txt") + + rm := &recoveryManager{ + FS: fs, + StorageFS: fs, + HomeDir: home, + BackupID: "b-undo-test", + RecoveryDir: recDir, + } + + diffs := []restorepkg.FileDiff{ + {Status: restorepkg.DiffModified, TargetPath: t1}, + {Status: restorepkg.DiffNew, TargetPath: t2}, + } + if err := rm.Prepare(diffs); err != nil { + t.Fatalf("prepare: %v", err) + } + + // Simulate restore apply: t1 modified to "post1", t2 created with "post2" + if err := os.WriteFile(t1, []byte("post1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(t2, []byte("post2\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := rm.RecordPostState(); err != nil { + t.Fatalf("record post state: %v", err) + } + + return rm, home, t1, t2 +} + +func TestRecovery_CheckUndoDrift(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(rm *recoveryManager, home, t1, t2 string) + expectErrFrag string + skipWindows bool + }{ + { + name: "clean state with zero drift passes", + mutate: func(rm *recoveryManager, home, t1, t2 string) {}, + }, + { + name: "content drift on t1 rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + if err := os.WriteFile(t1, []byte("user-drift-content\n"), 0644); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "content mismatch", + }, + { + name: "mode drift on t1 rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + if err := os.Chmod(t1, 0755); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "mode mismatch", + skipWindows: true, + }, + { + name: "deletion drift on t1 rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "missing", + }, + { + name: "unexpected existence drift when post was absent rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + // Mark t2 post-state as not existed, but leave file on disk + cleanT2 := paths.CanonicalPath(t2) + post := rm.Meta.TargetPostMap[cleanT2] + post.Existed = false + rm.Meta.TargetPostMap[cleanT2] = post + }, + expectErrFrag: "expected ~/file2.txt to be absent", + }, + { + name: "symlink drift on t1 rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + target := filepath.Join(home, "other.txt") + _ = os.WriteFile(target, []byte("x"), 0644) + if err := os.Symlink(target, t1); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "symlink", + skipWindows: true, + }, + { + name: "directory replacement drift on t1 rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(t1, 0755); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "directory", + }, + { + name: "tampered pre snapshot in recovery repo rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file1.txt")) + if err := os.WriteFile(preSnap, []byte("tampered-pre"), 0600); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "tampered or corrupted", + }, + { + name: "missing pre snapshot in recovery repo rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file1.txt")) + if err := os.Remove(preSnap); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "pre-snapshot", + }, + { + name: "symlink ancestor drift on nested target rejected", + mutate: func(rm *recoveryManager, home, t1, t2 string) { + nestedDir := filepath.Join(home, "sub") + if err := os.Mkdir(nestedDir, 0755); err != nil { + t.Fatal(err) + } + nestedTarget := filepath.Join(nestedDir, "nested.txt") + if err := os.WriteFile(nestedTarget, []byte("nest"), 0644); err != nil { + t.Fatal(err) + } + cleanNT := paths.CanonicalPath(nestedTarget) + h := sha256.Sum256([]byte("nest")) + rm.Meta.TargetPostMap[cleanNT] = targetPostState{ + TargetPath: nestedTarget, + RelPath: "sub/nested.txt", + Existed: true, + Mode: 0644, + SHA256: fmt.Sprintf("sha256:%x", h), + } + rm.Meta.TargetPreMap[cleanNT] = targetPreState{ + TargetPath: nestedTarget, + RelPath: "sub/nested.txt", + Existed: true, + Mode: 0644, + SHA256: fmt.Sprintf("sha256:%x", h), + } + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("sub/nested.txt")) + if err := os.MkdirAll(filepath.Dir(preSnap), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(preSnap, []byte("nest"), 0600); err != nil { + t.Fatal(err) + } + + otherDir := t.TempDir() + if err := os.RemoveAll(nestedDir); err != nil { + t.Fatal(err) + } + if err := os.Symlink(otherDir, nestedDir); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "symlink", + skipWindows: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.skipWindows && isWindows() { + t.Skip("skipping on Windows") + } + rm, home, t1, t2 := setupUndoTestScenario(t) + tt.mutate(rm, home, t1, t2) + + // Read file stats before check to assert ZERO writes + stat1Before, _ := os.Stat(t1) + stat2Before, _ := os.Stat(t2) + + err := rm.CheckUndoDrift() + if tt.expectErrFrag != "" { + if err == nil || !strings.Contains(err.Error(), tt.expectErrFrag) { + t.Fatalf("expected error containing %q, got %v", tt.expectErrFrag, err) + } + // Assert ZERO writes occurred + stat1After, _ := os.Stat(t1) + stat2After, _ := os.Stat(t2) + if (stat1Before == nil) != (stat1After == nil) { + t.Fatal("file1 existence changed during drift check") + } + if (stat2Before == nil) != (stat2After == nil) { + t.Fatal("file2 existence changed during drift check") + } + } else if err != nil { + t.Fatalf("unexpected drift error: %v", err) + } + }) + } +} + +func TestRecovery_UndoTargets_Success(t *testing.T) { //nolint:paralleltest // shared state + rm, home, t1, t2 := setupUndoTestScenario(t) + + // Additional target: mode 0000 restored + t3 := filepath.Join(home, "mode0000.txt") + if err := os.WriteFile(t3, []byte("mode0000-orig"), 0644); err != nil { + t.Fatal(err) + } + cleanT3 := paths.CanonicalPath(t3) + h3 := sha256.Sum256([]byte("mode0000-orig")) + rm.Meta.TargetPreMap[cleanT3] = targetPreState{ + TargetPath: t3, + RelPath: "mode0000.txt", + Existed: true, + Mode: 0000, + SHA256: fmt.Sprintf("sha256:%x", h3), + } + t3Snap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("mode0000.txt")) + if err := os.WriteFile(t3Snap, []byte("mode0000-orig"), 0600); err != nil { + t.Fatal(err) + } + rm.Meta.TargetPostMap[cleanT3] = targetPostState{ + TargetPath: t3, + RelPath: "mode0000.txt", + Existed: true, + Mode: 0644, + SHA256: fmt.Sprintf("sha256:%x", h3), + } + + outcome, err := rm.UndoTargets() + if err != nil { + t.Fatalf("unexpected undo error: %v (errors: %v)", err, outcome.Errors) + } + + // 1. Target 1 reverted to original bytes + data1, err := os.ReadFile(t1) + if err != nil { + t.Fatalf("read t1: %v", err) + } + if string(data1) != "orig1\n" { + t.Errorf("expected t1 to have 'orig1\\n', got %q", string(data1)) + } + + // 2. Target 2 was new, so it must be absent after undo + if _, err := os.Stat(t2); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected t2 to be removed/absent, found: %v", err) + } + + // 3. Target 3 mode checked (non-Windows) + if !isWindows() { + fi3, err := os.Stat(t3) + if err != nil { + t.Fatalf("stat t3: %v", err) + } + if fi3.Mode().Perm() != 0000 { + t.Errorf("expected t3 mode 0000, got %04o", fi3.Mode().Perm()) + } + } + + // 4. Status is undone + if rm.Meta.Status != "undone" { + t.Errorf("expected status undone, got %q", rm.Meta.Status) + } + + // 5. recovery-meta.json committed with undone status + metaBytes, err := os.ReadFile(filepath.Join(rm.RepoDir, "recovery-meta.json")) + if err != nil { + t.Fatalf("read meta: %v", err) + } + var savedMeta recoveryMetadata + if err := json.Unmarshal(metaBytes, &savedMeta); err != nil { + t.Fatalf("unmarshal meta: %v", err) + } + if savedMeta.Status != "undone" { + t.Errorf("expected meta file status undone, got %q", savedMeta.Status) + } + + // 6. Outcome accounting + if len(outcome.Unresolved) != 0 { + t.Errorf("expected 0 unresolved, got %v", outcome.Unresolved) + } + if len(outcome.Reverted) != 3 { + t.Errorf("expected 3 reverted targets, got %v", outcome.Reverted) + } +} + +func TestRecovery_UndoTargets_PartialFailure(t *testing.T) { //nolint:paralleltest // shared state + rm, _, _, t2 := setupUndoTestScenario(t) + + // Mutate t2 to be a directory: rollbackNewTarget will refuse to remove it! + if err := os.Remove(t2); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(t2, 0755); err != nil { + t.Fatal(err) + } + userFile := filepath.Join(t2, "preserve-me.txt") + if err := os.WriteFile(userFile, []byte("precious"), 0644); err != nil { + t.Fatal(err) + } + + // Update t2 post-state so drift check passes for existence + cleanT2 := paths.CanonicalPath(t2) + post := rm.Meta.TargetPostMap[cleanT2] + post.SHA256 = "" // skip hash check on dir if not checked + rm.Meta.TargetPostMap[cleanT2] = post + + // Call rollbackTarget directly or let UndoTargets execute + outcome, err := rm.UndoTargets() + if err == nil { + t.Fatal("expected error on partial failure, got nil") + } + + // Assert Status is failed + if rm.Meta.Status != "failed" { + t.Errorf("expected status failed, got %q", rm.Meta.Status) + } + + // Reverted/Unresolved accounting + if len(outcome.Unresolved) == 0 { + t.Errorf("expected unresolved target for directory refusal, got %v", outcome.Unresolved) + } + if len(outcome.Errors) == 0 { + t.Error("expected non-empty outcome.Errors") + } + + // User directory and file must be preserved + if _, err := os.Stat(userFile); err != nil { + t.Errorf("user file was damaged during partial failure: %v", err) + } +} + +func TestRecovery_UndoTargets_TamperRefused(t *testing.T) { //nolint:paralleltest // shared state + rm, _, t1, _ := setupUndoTestScenario(t) + + // Tamper pre-snapshot for t1 + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file1.txt")) + if err := os.WriteFile(preSnap, []byte("tampered-bytes"), 0600); err != nil { + t.Fatal(err) + } + + outcome, err := rm.UndoTargets() + if err == nil { + t.Fatal("expected error when pre-snapshot is tampered, got nil") + } + if rm.Meta.Status != "failed" { + t.Errorf("expected status failed, got %q", rm.Meta.Status) + } + if len(outcome.Unresolved) == 0 { + t.Fatalf("expected unresolved targets on tampered snapshot, got %v", outcome.Reverted) + } + // Assert live file t1 was NOT modified to tampered bytes + data, _ := os.ReadFile(t1) + if string(data) == "tampered-bytes" { + t.Fatal("target file was overwritten with tampered bytes") + } +} + +func TestRecovery_ErrorPrivacy(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + fs := newHomeFS(home) + outsideDir := t.TempDir() + + // 1. findLatestAppliedPoint with escaping path + _, err1 := findLatestAppliedPoint(fs, outsideDir, home) + if err1 != nil && strings.Contains(err1.Error(), home) { + t.Errorf("findLatestAppliedPoint leaked home directory in error: %q", err1.Error()) + } + + // 2. CheckUndoDrift with invalid target outside home + rm := &recoveryManager{ + FS: fs, + StorageFS: fs, + HomeDir: home, + Meta: recoveryMetadata{ + Status: "applied", + TargetPostMap: map[string]targetPostState{ + filepath.Join(outsideDir, "out.txt"): { + TargetPath: filepath.Join(outsideDir, "out.txt"), + Existed: true, + }, + }, + }, + } + err2 := rm.CheckUndoDrift() + if err2 != nil && strings.Contains(err2.Error(), home) { + t.Errorf("CheckUndoDrift leaked home directory in error: %q", err2.Error()) + } + + // 3. UndoTargets with escaping target in pre-map + rm3 := &recoveryManager{ + FS: fs, + StorageFS: fs, + HomeDir: home, + PointID: "rec-privacy", + PointDir: filepath.Join(home, ".bak", "recovery", "rec-privacy"), + RepoDir: filepath.Join(home, ".bak", "recovery", "rec-privacy", "repo"), + Meta: recoveryMetadata{ + PointID: "rec-privacy", + Status: "applied", + TargetPreMap: map[string]targetPreState{ + filepath.Join(outsideDir, "out.txt"): { + TargetPath: filepath.Join(outsideDir, "out.txt"), + Existed: true, + }, + }, + }, + } + _, err3 := rm3.UndoTargets() + if err3 != nil && strings.Contains(err3.Error(), home) { + t.Errorf("UndoTargets leaked home directory in error: %q", err3.Error()) + } +} + +func TestRecovery_UndoTargets_PersistFailedStateMetadata_OnGitFailure(t *testing.T) { + t.Parallel() + rm, _, _, _ := setupUndoTestScenario(t) + // Corrupt git repo so git.OpenRepo fails + if err := os.RemoveAll(filepath.Join(rm.RepoDir, ".git")); err != nil { + t.Fatal(err) + } + + outcome, err := rm.UndoTargets() + if err == nil { + t.Fatal("expected error on git open failure, got nil") + } + if rm.Meta.Status != "failed" { + t.Errorf("expected in-memory status failed, got %q", rm.Meta.Status) + } + + // Verify that recovery-meta.json on disk was updated with Status = "failed" + metaBytes, readErr := os.ReadFile(filepath.Join(rm.RepoDir, "recovery-meta.json")) + if readErr != nil { + t.Fatalf("read recovery-meta.json: %v", readErr) + } + var diskMeta recoveryMetadata + if err := json.Unmarshal(metaBytes, &diskMeta); err != nil { + t.Fatalf("unmarshal recovery-meta.json: %v", err) + } + if diskMeta.Status != "failed" { + t.Errorf("expected on-disk status failed, got %q", diskMeta.Status) + } + if len(outcome.Errors) == 0 { + t.Error("expected outcome.Errors to contain git failure") + } +} + +func TestRecovery_UndoTargets_PersistFailedStateMetadata_WriteErrorSurfaced(t *testing.T) { + t.Parallel() + rm, _, _, _ := setupUndoTestScenario(t) + // Corrupt git repo so git.OpenRepo fails + if err := os.RemoveAll(filepath.Join(rm.RepoDir, ".git")); err != nil { + t.Fatal(err) + } + + // Inject storageFS that fails when writing recovery-meta.json + rm.StorageFS = &pathErrorStorageFS{ + FileSystem: &OSFileSystem{}, + failOnWrite: "recovery-meta.json", + } + + outcome, err := rm.UndoTargets() + if err == nil { + t.Fatal("expected error on git failure + metadata write failure, got nil") + } + foundWriteErr := false + for _, e := range outcome.Errors { + if strings.Contains(e.Error(), "write") || strings.Contains(e.Error(), "recovery-meta.json") { + foundWriteErr = true + break + } + } + if !foundWriteErr { + t.Errorf("expected metadata write error in outcome.Errors, got %v", outcome.Errors) + } +} + +func TestRecovery_FindLatestAppliedPoint_MismatchedPointID_Skipped(t *testing.T) { + t.Parallel() + home := t.TempDir() + fs := newHomeFS(home) + recBase := filepath.Join(home, ".bak", "recovery") + + pointDir := filepath.Join(recBase, "real-entry-id", "repo") + if err := os.MkdirAll(pointDir, 0755); err != nil { + t.Fatal(err) + } + // Metadata inside real-entry-id claims PointID is spoofed-other-id + meta := recoveryMetadata{ + PointID: "spoofed-other-id", + Status: "applied", + CreatedAt: time.Now(), + } + data, _ := json.MarshalIndent(meta, "", " ") + _ = os.WriteFile(filepath.Join(pointDir, "recovery-meta.json"), data, 0644) + + res, err := findLatestAppliedPoint(fs, recBase, home) + if !errors.Is(err, errNoAppliedPoint) { + t.Fatalf("expected errNoAppliedPoint when PointID does not match directory name, got res=%v, err=%v", res, err) + } +} + +func TestRecovery_CheckUndoDrift_PrePostCorrespondenceMismatch(t *testing.T) { + t.Parallel() + rm, home, t1, _ := setupUndoTestScenario(t) + cleanT1 := paths.CanonicalPath(t1) + + // Tamper pre-state so pre.TargetPath points to a different file + spoofedTarget := filepath.Join(home, "other-file.txt") + pre := rm.Meta.TargetPreMap[cleanT1] + pre.TargetPath = spoofedTarget + rm.Meta.TargetPreMap[cleanT1] = pre + + err := rm.CheckUndoDrift() + if err == nil || !strings.Contains(err.Error(), "correspondence") { + t.Fatalf("expected error containing 'correspondence', got %v", err) + } +} + +func TestRecovery_UndoTargets_PrePostCorrespondenceMismatch(t *testing.T) { + t.Parallel() + rm, home, t1, _ := setupUndoTestScenario(t) + cleanT1 := paths.CanonicalPath(t1) + + // Tamper pre-state so pre.TargetPath points to a different file + spoofedTarget := filepath.Join(home, "other-file.txt") + pre := rm.Meta.TargetPreMap[cleanT1] + pre.TargetPath = spoofedTarget + rm.Meta.TargetPreMap[cleanT1] = pre + + outcome, err := rm.UndoTargets() + if err == nil || !strings.Contains(err.Error(), "correspondence") { + t.Fatalf("expected error containing 'correspondence', got %v (outcome errors: %v)", err, outcome.Errors) + } + // Assert live target was not touched + if _, statErr := os.Stat(spoofedTarget); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected spoofed target not to exist, got stat err: %v", statErr) + } +} diff --git a/internal/actions/restore.go b/internal/actions/restore.go index 2a403f9..b390c36 100644 --- a/internal/actions/restore.go +++ b/internal/actions/restore.go @@ -19,12 +19,19 @@ import ( // dependencies. All OS operations that are the action's responsibility // go through a.FS. type RestoreAction struct { - FS FileSystem - BackupDir string // resolved backup directory - DryRun bool - Force bool - Verbose bool - GitDir string // optional git repo for safety commits + FS FileSystem + BackupDir string // resolved backup directory + BakVersion string // version of running bak tool; zero value means unknown + DryRun bool + Force bool + Verbose bool + GitDir string // optional git repo for safety commits + + // RecoveryDir optionally overrides the base recovery directory. + // When empty, defaults to /.bak/recovery. + RecoveryDir string + + storageFS FileSystem // unexported injection for recovery storage in tests // ProgressFn is an optional callback invoked once per file during restore. // When nil (default), no progress is reported. @@ -48,6 +55,69 @@ func (a *RestoreAction) ResolveBackup(backupID string) error { return nil } +// handlePostStateFailure attempts rollback when post-state recording fails. +func handlePostStateFailure(recMgr *recoveryManager, diffs []restorepkg.FileDiff, err error) error { + var attempted []string + for _, d := range diffs { + if d.Status == restorepkg.DiffNew || d.Status == restorepkg.DiffModified { + attempted = append(attempted, d.TargetPath) + } + } + outcome := recMgr.Rollback(attempted) + var errSuffix string + if len(outcome.Errors) > 0 { + errMsgs := make([]string, 0, len(outcome.Errors)) + for _, e := range outcome.Errors { + errMsgs = append(errMsgs, e.Error()) + } + errSuffix = "; " + strings.Join(errMsgs, "; ") + } + if len(outcome.Unresolved) > 0 { + return fmt.Errorf("record post-restore state: %w (rollback unresolved for %d file(s); recovery point: %s%s)", err, len(outcome.Unresolved), recMgr.PointID, errSuffix) + } + return fmt.Errorf("record post-restore state: %w (all targets rolled back to pre-restore state; recovery point: %s%s)", err, recMgr.PointID, errSuffix) +} + +// showDryRun formats and displays dry-run diffs, and if DryRun is enabled, +// writes the summary and reports completion. +func (a *RestoreAction) showDryRun(out io.Writer, diffs []restorepkg.FileDiff) error { + if err := a.printDryRunDiff(out, diffs); err != nil { + return err + } + if !a.DryRun { + return nil + } + if _, err := fmt.Fprintf(out, "Dry-run complete. %d file(s) would be restored, %d unchanged, %d missing.\n", + countByStatus(diffs, restorepkg.DiffNew)+countByStatus(diffs, restorepkg.DiffModified), + countByStatus(diffs, restorepkg.DiffUnchanged), + countByStatus(diffs, restorepkg.DiffMissing), + ); err != nil { + return fmt.Errorf("write dry-run summary: %w", err) + } + return nil +} + +// finishRestore handles post-apply reporting, post-state recording, and error propagation. +func (a *RestoreAction) finishRestore(out io.Writer, m *manifest.Manifest, diffs []restorepkg.FileDiff, recMgr *recoveryManager, restored, skipped, failed, degraded int, applyErr error) error { + if failed > 0 || applyErr != nil { + if repErr := reportRestore(out, m, restored, skipped, failed, degraded); repErr != nil && applyErr != nil { + return errors.Join(applyErr, repErr) + } else if repErr != nil { + return repErr + } + return applyErr + } + + if err := recMgr.RecordPostState(); err != nil { + if repErr := reportRestore(out, m, 0, skipped, countByStatus(diffs, restorepkg.DiffNew)+countByStatus(diffs, restorepkg.DiffModified), degraded); repErr != nil { + return errors.Join(handlePostStateFailure(recMgr, diffs, err), repErr) + } + return handlePostStateFailure(recMgr, diffs, err) + } + + return reportRestore(out, m, restored, skipped, failed, degraded) +} + // Run executes the restore workflow: load manifest, compute diffs, and // optionally apply changes. Each phase is delegated to a helper to keep the // orchestration readable and below the cognitive-complexity threshold. @@ -60,6 +130,10 @@ func (a *RestoreAction) Run() error { return fmt.Errorf("load manifest: %w", err) } + if err := a.warnBakVersionMismatch(m.ID, m.BakVersion, errOut); err != nil { + return err + } + // 2. Get home directory. homeDir, err := a.FS.UserHomeDir() if err != nil { @@ -72,15 +146,11 @@ func (a *RestoreAction) Run() error { return fmt.Errorf("compute dry-run: %w", err) } - // 4. Show diffs. - a.printDryRunDiff(out, diffs) - + // 4. Show diffs / handle dry-run. + if err := a.showDryRun(out, diffs); err != nil { + return err + } if a.DryRun { - _, _ = fmt.Fprintf(out, "Dry-run complete. %d file(s) would be restored, %d unchanged, %d missing.\n", - countByStatus(diffs, restorepkg.DiffNew)+countByStatus(diffs, restorepkg.DiffModified), - countByStatus(diffs, restorepkg.DiffUnchanged), - countByStatus(diffs, restorepkg.DiffMissing), - ) return nil } @@ -98,20 +168,24 @@ func (a *RestoreAction) Run() error { return nil } - // 7. Apply restore. - restored, skipped, failed, degraded, applyErr := a.applyRestore(m, diffs, out, errOut) - - // 8. Report results. - reportRestore(out, m, restored, skipped, failed, degraded) + // 7. Setup recovery manager and prepare pre-state before any target mutation. + recMgr := &recoveryManager{ + FS: a.FS, + StorageFS: a.storageFS, + HomeDir: homeDir, + BackupID: m.ID, + RecoveryDir: a.RecoveryDir, + } - if failed > 0 { - if applyErr != nil { - return fmt.Errorf("apply restore: %w", applyErr) - } - return fmt.Errorf("apply restore: %d file(s) failed", failed) + if err := recMgr.Prepare(diffs); err != nil { + return fmt.Errorf("prepare recovery snapshot: %w", err) } - return nil + // 8. Apply restore with recovery and automatic rollback on first failure. + restored, skipped, failed, degraded, applyErr := a.applyRestore(m, diffs, recMgr, out, errOut) + + // 9. Post-apply reporting and recovery completion. + return a.finishRestore(out, m, diffs, recMgr, restored, skipped, failed, degraded, applyErr) } // resolveWriters returns the output and error writers, falling back to @@ -128,20 +202,58 @@ func (a *RestoreAction) resolveWriters() (io.Writer, io.Writer) { return out, errOut } +const unknownVersion = "unknown" + +// warnBakVersionMismatch checks for version compatibility between the running bak +// tool and the version that created the backup. Any mismatch or unknown/dev version +// on either side produces a warning on errOut naming the backup id and both versions, +// but does not block restore. +func (a *RestoreAction) warnBakVersionMismatch(backupID, manifestBakVersion string, errOut io.Writer) error { + runningVer := a.BakVersion + if runningVer == "" { + runningVer = unknownVersion + } + backupVer := manifestBakVersion + if backupVer == "" { + backupVer = unknownVersion + } + + id := backupID + if id == "" { + id = unknownVersion + } + + isUnknownOrDev := func(v string) bool { + return v == unknownVersion || v == "dev" + } + + if isUnknownOrDev(runningVer) || isUnknownOrDev(backupVer) || runningVer != backupVer { + if _, err := fmt.Fprintf(errOut, "warning: backup %s was created with bak %s, running version is %s\n", id, backupVer, runningVer); err != nil { + return fmt.Errorf("write version warning: %w", err) + } + } + return nil +} + // printDryRunDiff writes the per-file dry-run diff to out. When verbose and // a file is modified with a non-empty diff, the unified diff is appended. -func (a *RestoreAction) printDryRunDiff(out io.Writer, diffs []restorepkg.FileDiff) { +func (a *RestoreAction) printDryRunDiff(out io.Writer, diffs []restorepkg.FileDiff) error { if len(diffs) == 0 { - return + return nil } - _, _ = fmt.Fprintln(out, "Dry-run diff:") + var b strings.Builder + b.WriteString("Dry-run diff:\n") for _, d := range diffs { - _, _ = fmt.Fprintf(out, " [%s] %s\n", d.Status, d.SourcePath) + fmt.Fprintf(&b, " [%s] %s\n", d.Status, d.SourcePath) if d.Status == restorepkg.DiffModified && d.Diff != "" && a.Verbose { - _, _ = fmt.Fprint(out, d.Diff) + b.WriteString(d.Diff) } } - _, _ = fmt.Fprintln(out) + b.WriteByte('\n') + if _, err := fmt.Fprint(out, b.String()); err != nil { + return fmt.Errorf("write dry-run diff: %w", err) + } + return nil } // validateManifest validates checksums of all backed-up files against the @@ -162,7 +274,9 @@ func (a *RestoreAction) confirmRestore(out, errOut io.Writer) (bool, error) { if a.Force { return true, nil } - _, _ = fmt.Fprint(out, "Apply restore? [y/N]: ") + if _, err := fmt.Fprint(out, "Apply restore? [y/N]: "); err != nil { + return false, fmt.Errorf("write confirmation prompt: %w", err) + } stdin := a.Stdin if stdin == nil { stdin = os.Stdin @@ -174,91 +288,126 @@ func (a *RestoreAction) confirmRestore(out, errOut io.Writer) (bool, error) { } answer = strings.TrimSpace(strings.ToLower(answer)) if answer != "y" && answer != "yes" { - _, _ = fmt.Fprintln(errOut, "Restore cancelled.") + if _, err := fmt.Fprintln(errOut, "Restore cancelled."); err != nil { + return false, fmt.Errorf("write cancellation: %w", err) + } return false, nil } return true, nil } -// applyRestore copies each new/modified file, skipping unchanged and missing -// files. Progress is reported via ProgressFn when set. Returns the counts of -// restored, skipped, failed, and degraded files along with an aggregated error if any -// copy or chmod failed. -func (a *RestoreAction) applyRestore(m *manifest.Manifest, diffs []restorepkg.FileDiff, _, errOut io.Writer) (restored, skipped, failed, degraded int, err error) { +func formatRollbackOutcome(outcome rollbackOutcome) string { + var base string + switch { + case len(outcome.Unresolved) > 0: + base = fmt.Sprintf("rollback unresolved for %d file(s); recovery point: %s", len(outcome.Unresolved), outcome.PointID) + case len(outcome.Reverted) > 0: + base = fmt.Sprintf("reverted %d attempted target(s); recovery point: %s", len(outcome.Reverted), outcome.PointID) + default: + base = fmt.Sprintf("recovery point: %s", outcome.PointID) + } + if len(outcome.Errors) > 0 { + errMsgs := make([]string, 0, len(outcome.Errors)) + for _, e := range outcome.Errors { + errMsgs = append(errMsgs, e.Error()) + } + return fmt.Sprintf("%s; %s", base, strings.Join(errMsgs, "; ")) + } + return base +} + +func buildModeMap(m *manifest.Manifest) map[string]uint32 { modeMap := make(map[string]uint32) for _, am := range m.Adapters { for _, item := range am.Items { modeMap[item.BackupPath] = item.Mode } } + return modeMap +} +func countRestoreTargets(diffs []restorepkg.FileDiff) int { filesTotal := 0 for _, d := range diffs { if d.Status == restorepkg.DiffNew || d.Status == restorepkg.DiffModified { filesTotal++ } } + return filesTotal +} + +// applyRestore copies each new/modified file, stopping at the first failure +// and attempting rollback of all attempted targets. Returns the counts of +// restored, skipped, failed, and degraded files along with an aggregated error if any +// copy or chmod failed. +func (a *RestoreAction) applyRestore(m *manifest.Manifest, diffs []restorepkg.FileDiff, recMgr *recoveryManager, _, errOut io.Writer) (restored, skipped, failed, degraded int, err error) { + modeMap := buildModeMap(m) + filesTotal := countRestoreTargets(diffs) filesDone := 0 - var copyErrs []error + var attemptedTargets []string for _, d := range diffs { - restored, skipped, failed, degraded = a.applyDiff(d, modeMap, filesTotal, &filesDone, restored, skipped, failed, degraded, ©Errs, errOut) - } - if len(copyErrs) > 0 { - err = errors.Join(copyErrs...) - } - return restored, skipped, failed, degraded, err -} - -// applyDiff applies one diff entry and returns the updated counters. Copy -// errors accumulate in errs so the caller can join them after the loop. -func (a *RestoreAction) applyDiff(d restorepkg.FileDiff, modeMap map[string]uint32, filesTotal int, filesDone *int, restored, skipped, failed, degraded int, errs *[]error, errOut io.Writer) (int, int, int, int) { - switch d.Status { - case restorepkg.DiffNew, restorepkg.DiffModified: - *filesDone++ - if a.ProgressFn != nil { - a.ProgressFn(d.SourcePath, *filesDone, filesTotal) - } - mode := modeMap[d.BackupPath] - if mode == 0 { - degraded++ - } - if err := a.restoreFile(d, mode); err != nil { - failed++ - *errs = append(*errs, fmt.Errorf("restore %s: %w", d.SourcePath, err)) - if a.Verbose { - _, _ = fmt.Fprintf(errOut, "restore %s: %v\n", d.SourcePath, err) + switch d.Status { + case restorepkg.DiffNew, restorepkg.DiffModified: + filesDone++ + if a.ProgressFn != nil { + a.ProgressFn(d.SourcePath, filesDone, filesTotal) + } + mode := modeMap[d.BackupPath] + if mode == 0 { + degraded++ + } + attemptedTargets = append(attemptedTargets, d.TargetPath) + if err := a.restoreFile(d, mode); err != nil { + failed++ + if a.Verbose { + if _, wErr := fmt.Fprintf(errOut, "restore %s: %v\n", d.SourcePath, err); wErr != nil { + err = errors.Join(err, fmt.Errorf("write verbose log: %w", wErr)) + } + } + var outcome rollbackOutcome + if recMgr != nil { + outcome = recMgr.Rollback(attemptedTargets) + } + rollbackMsg := formatRollbackOutcome(outcome) + return restored, skipped, failed, degraded, fmt.Errorf("restore %s: %w (%s)", d.SourcePath, err, rollbackMsg) } - } else { restored++ - } - case restorepkg.DiffUnchanged: - skipped++ - case restorepkg.DiffMissing: - skipped++ - if a.Verbose { - _, _ = fmt.Fprintf(errOut, "warning: missing backup file %s\n", d.BackupPath) + case restorepkg.DiffUnchanged: + skipped++ + case restorepkg.DiffMissing: + skipped++ + if a.Verbose { + if _, wErr := fmt.Fprintf(errOut, "warning: missing backup file %s\n", d.BackupPath); wErr != nil { + return restored, skipped, failed, degraded, fmt.Errorf("write verbose warning: %w", wErr) + } + } } } - return restored, skipped, failed, degraded + return restored, skipped, failed, degraded, nil } // reportRestore writes the final restore summary to out, including the failed // count only when at least one file failed and degraded permissions warning. -func reportRestore(out io.Writer, m *manifest.Manifest, restored, skipped, failed, degraded int) { +func reportRestore(out io.Writer, m *manifest.Manifest, restored, skipped, failed, degraded int) error { + var b strings.Builder if failed > 0 { - _, _ = fmt.Fprintf(out, "Restore failed: %s\n", m.ID) + fmt.Fprintf(&b, "Restore failed: %s\n", m.ID) } else { - _, _ = fmt.Fprintf(out, "Restore complete: %s\n", m.ID) + fmt.Fprintf(&b, "Restore complete: %s\n", m.ID) } - _, _ = fmt.Fprintf(out, " Restored: %d\n", restored) - _, _ = fmt.Fprintf(out, " Skipped: %d\n", skipped) + fmt.Fprintf(&b, " Restored: %d\n", restored) + fmt.Fprintf(&b, " Skipped: %d\n", skipped) if failed > 0 { - _, _ = fmt.Fprintf(out, " Failed: %d\n", failed) + fmt.Fprintf(&b, " Failed: %d\n", failed) } if degraded > 0 { - _, _ = fmt.Fprintf(out, " Warning: degraded permissions (%d file(s) lack mode metadata)\n", degraded) + fmt.Fprintf(&b, " Warning: degraded permissions (%d file(s) lack mode metadata)\n", degraded) } + if _, err := fmt.Fprint(out, b.String()); err != nil { + return fmt.Errorf("write restore report: %w", err) + } + return nil } // restoreFile copies a single file from the backup directory to the @@ -270,7 +419,7 @@ func (a *RestoreAction) restoreFile(d restorepkg.FileDiff, mode uint32) error { // Security: validate source path stays under backup directory. cleanSrc := paths.CanonicalPath(src) cleanBackupDir := paths.CanonicalPath(a.BackupDir) + "/" - if !strings.HasPrefix(cleanSrc, cleanBackupDir) { + if !strings.HasPrefix(strings.ToLower(cleanSrc), strings.ToLower(cleanBackupDir)) { return fmt.Errorf("source path escapes backup directory") } @@ -281,7 +430,7 @@ func (a *RestoreAction) restoreFile(d restorepkg.FileDiff, mode uint32) error { } cleanTarget := paths.CanonicalPath(d.TargetPath) cleanHome := paths.CanonicalPath(homeDir) + "/" - if !strings.HasPrefix(cleanTarget, cleanHome) { + if !strings.HasPrefix(strings.ToLower(cleanTarget), strings.ToLower(cleanHome)) { return fmt.Errorf("target path escapes home directory") } diff --git a/internal/actions/restore_test.go b/internal/actions/restore_test.go index 0dc2309..c7bee3a 100644 --- a/internal/actions/restore_test.go +++ b/internal/actions/restore_test.go @@ -17,9 +17,9 @@ import ( // --- restore helpers ---------------------------------------------------- -// createBackupForRestore creates a real backup inside home so it can be -// restored. -func createBackupForRestore(t *testing.T, home string) string { +// createTestBackupForRestore creates a real backup inside home with customizable +// bakVersion, schemaVersion, and file mode. +func createTestBackupForRestore(t *testing.T, home, bakVersion, schemaVersion string, mode uint32) string { t.Helper() bakDir := filepath.Join(home, ".bak") @@ -30,11 +30,10 @@ func createBackupForRestore(t *testing.T, home string) string { t.Fatal(err) } - // Create a manifest. - m := manifest.New(backupID, "linux", "testhost", "test", "quick", []string{"config"}) + m := manifest.New(backupID, "linux", "testhost", bakVersion, "quick", []string{"config"}) + m.Version = schemaVersion configDir := filepath.Join(home, ".config", "bak") - // Write a backed-up file. adapterDir := filepath.Join(backupDir, "test-adapter") if err := os.MkdirAll(adapterDir, 0755); err != nil { t.Fatal(err) @@ -46,7 +45,6 @@ func createBackupForRestore(t *testing.T, home string) string { } h := sha256.Sum256(testContent) - m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ { Category: "config", @@ -54,6 +52,7 @@ func createBackupForRestore(t *testing.T, home string) string { BackupPath: "test-adapter/config.json", Hash: fmt.Sprintf("sha256:%x", h), Size: int64(len(testContent)), + Mode: mode, }, }) if err := m.Save(backupDir); err != nil { @@ -68,6 +67,18 @@ func createBackupForRestore(t *testing.T, home string) string { return backupID } +// createBackupForRestore creates a standard backup inside home. +func createBackupForRestore(t *testing.T, home string) string { + t.Helper() + return createTestBackupForRestore(t, home, "test", manifest.ManifestVersion, 0) +} + +// createCustomManifestBackupForRestore creates a backup with custom versions and mode 0644. +func createCustomManifestBackupForRestore(t *testing.T, home, bakVersion, schemaVersion string) string { + t.Helper() + return createTestBackupForRestore(t, home, bakVersion, schemaVersion, 0644) +} + // --- tests ------------------------------------------------------------- func TestRestoreAction_DryRun(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending @@ -318,6 +329,114 @@ func TestRestoreAction_CountByStatus_AllTypes(t *testing.T) { //nolint:parallelt } } +func TestRestoreAction_RestoreFile_CaseContainment(t *testing.T) { //nolint:paralleltest // shared state + tests := []struct { + name string + homeDir string + backupDir string + backupPath string + targetPath string + wantEscape bool + errSnippet string + }{ + { + name: "in_bounds_case_variant_target_accepted", + homeDir: "/home/alice", + backupDir: "/home/alice/.bak/backups/test", + backupPath: "safe.txt", + targetPath: "/HOME/alice/safe.txt", + wantEscape: false, + }, + { + name: "in_bounds_case_variant_backup_dir_accepted", + homeDir: "/home/alice", + backupDir: "/HOME/alice/.bak/backups/test", + backupPath: "safe.txt", + targetPath: "/home/alice/safe.txt", + wantEscape: false, + }, + { + name: "escape_target_parent_traversal_uppercase", + homeDir: "/home/alice", + backupDir: "/home/alice/.bak/backups/test", + backupPath: "safe.txt", + targetPath: "/HOME/alice/../../etc/passwd", + wantEscape: true, + errSnippet: "target path escapes home directory", + }, + { + name: "escape_target_sibling_dir_case_variant", + homeDir: "/home/alice", + backupDir: "/home/alice/.bak/backups/test", + backupPath: "safe.txt", + targetPath: "/HOME/alice-sibling/escape.txt", + wantEscape: true, + errSnippet: "target path escapes home directory", + }, + { + name: "escape_backup_path_traversal_case_variant", + homeDir: "/home/alice", + backupDir: "/home/alice/.bak/backups/test", + backupPath: "../OTHER/escape.txt", + targetPath: "/home/alice/safe.txt", + wantEscape: true, + errSnippet: "source path escapes backup directory", + }, + { + name: "escape_backup_path_windows_slash_case_variant", + homeDir: "/home/alice", + backupDir: "/home/alice/.bak/backups/test", + backupPath: "..\\ESCAPE\\file.txt", + targetPath: "/home/alice/safe.txt", + wantEscape: true, + errSnippet: "source path escapes backup directory", + }, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest + srcFile := filepath.Join(tt.backupDir, tt.backupPath) + mockFS := &MockFileSystem{ + HomeDir: tt.homeDir, + StatResult: make(map[string]MockStatResult), + Files: map[string][]byte{ + srcFile: []byte("content"), + }, + } + + action := &RestoreAction{ + FS: mockFS, + BackupDir: tt.backupDir, + } + + err := action.restoreFile(restorepkg.FileDiff{ + BackupPath: tt.backupPath, + TargetPath: tt.targetPath, + }, 0) + + if tt.wantEscape { + if err == nil { + t.Fatalf("expected error for escaping path, got nil") + } + if !strings.Contains(err.Error(), tt.errSnippet) { + t.Errorf("error %q should contain %q", err.Error(), tt.errSnippet) + } + // Verify refusal: nothing was written to the target path. + if _, ok := mockFS.Files[tt.targetPath]; ok { + t.Errorf("expected target %q not to be written in mock FS after refusal", tt.targetPath) + } + } else { + if err != nil { + t.Fatalf("expected in-bounds case-variant path to succeed, got: %v", err) + } + if _, ok := mockFS.Files[tt.targetPath]; !ok { + t.Errorf("expected target %q to be written in mock FS, but was not", tt.targetPath) + } + } + }) + } +} + func TestRestoreAction_RestoreFile_PathTraversalBackupDir(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending home := t.TempDir() action := &RestoreAction{ @@ -344,9 +463,13 @@ func TestRestoreAction_RestoreFile_PathTraversalTarget(t *testing.T) { //nolint: bakDir := filepath.Join(home, ".bak") backupsDir := filepath.Join(bakDir, "backups") backupDir := filepath.Join(backupsDir, "test") - os.MkdirAll(backupDir, 0755) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatalf("mkdir backup dir: %v", err) + } srcFile := filepath.Join(backupDir, "safe.txt") - os.WriteFile(srcFile, []byte("content"), 0644) + if err := os.WriteFile(srcFile, []byte("content"), 0644); err != nil { + t.Fatalf("write src file: %v", err) + } action := &RestoreAction{ FS: newHomeFS(home), @@ -391,7 +514,9 @@ func TestRestoreAction_RestoreFile_CopyFile_Success(t *testing.T) { //nolint:par // Create only the backup directory structure (no real source file). bakDir := filepath.Join(home, ".bak") backupsDir := filepath.Join(bakDir, "backups", "test") - os.MkdirAll(backupsDir, 0755) + if err := os.MkdirAll(backupsDir, 0755); err != nil { + t.Fatalf("mkdir backups dir: %v", err) + } // Source file exists ONLY in the mock FS, forcing the code path // through a.FS.CopyFile() instead of os.Open. @@ -429,7 +554,9 @@ func TestRestoreAction_RestoreFile_CopyFile_Error(t *testing.T) { //nolint:paral bakDir := filepath.Join(home, ".bak") backupsDir := filepath.Join(bakDir, "backups", "test") - os.MkdirAll(backupsDir, 0755) + if err := os.MkdirAll(backupsDir, 0755); err != nil { + t.Fatalf("mkdir backups dir: %v", err) + } srcPath := filepath.Join(backupsDir, "safe.txt") mockFS := &MockFileSystem{ @@ -642,8 +769,44 @@ func TestRestoreAction_NilWritersFallback(t *testing.T) { //nolint:paralleltest // errorReader returns an error on every Read call. type errorReader struct{} +var _ io.Reader = (*errorReader)(nil) + func (e *errorReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +// failingWriter returns an error on every Write call. +type failingWriter struct{} + +var _ io.Writer = (*failingWriter)(nil) + +func (f *failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestRestoreAction_WritersErrorHandling(t *testing.T) { //nolint:paralleltest // shared state + action := &RestoreAction{} + fw := &failingWriter{} + + // printDryRunDiff error propagation + diffs := []restorepkg.FileDiff{{Status: restorepkg.DiffNew, SourcePath: "/a"}} + if err := action.printDryRunDiff(fw, diffs); err == nil { + t.Error("printDryRunDiff should return error when writer fails") + } + + // warnBakVersionMismatch error propagation + if err := action.warnBakVersionMismatch("test-id", "1.0.0", fw); err == nil { + t.Error("warnBakVersionMismatch should return error when writer fails") + } + + // confirmRestore prompt write error propagation + if _, err := action.confirmRestore(fw, fw); err == nil { + t.Error("confirmRestore should return error when prompt write fails") + } + + // reportRestore error propagation + m := &manifest.Manifest{ID: "test-id"} + if err := reportRestore(fw, m, 1, 0, 0, 0); err == nil { + t.Error("reportRestore should return error when writer fails") + } +} + func TestRestoreAction_CancelPrompt_AnswerNo(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending home := t.TempDir() backupID := createBackupForRestore(t, home) @@ -1026,11 +1189,16 @@ type chmodFailingFS struct { FileSystem } +var _ FileSystem = (*chmodFailingFS)(nil) + func (c *chmodFailingFS) Chmod(name string, _ os.FileMode) error { return fmt.Errorf("chmod %s: permission denied", name) } func TestRestoreAction_ChmodFailure_SurfacesAsError(t *testing.T) { //nolint:paralleltest // shared state + if isWindows() { + t.Skip("skipping chmod failure test on Windows: POSIX permission bits do not exist, so restore skips chmod entirely and can never surface a chmod error") + } home := t.TempDir() bakDir := filepath.Join(home, ".bak") backupsDir := filepath.Join(bakDir, "backups") @@ -1086,3 +1254,593 @@ func TestRestoreAction_ChmodFailure_SurfacesAsError(t *testing.T) { //nolint:par t.Errorf("error %q should mention chmod", err.Error()) } } + +// partialCopyFailingFS wraps a FileSystem double to simulate a copy failure with partial write. +type partialCopyFailingFS struct { + FileSystem + failOnDst string + failErr error +} + +var _ FileSystem = (*partialCopyFailingFS)(nil) + +func (p *partialCopyFailingFS) CopyFile(src, dst string) error { + if dst == p.failOnDst { + if err := p.WriteFile(dst, []byte("corrupted partial write"), 0644); err != nil { + return err + } + return p.failErr + } + return p.FileSystem.CopyFile(src, dst) +} + +func TestRestoreAction_RollbackOnFailure(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + bakDir := filepath.Join(home, ".bak") + backupsDir := filepath.Join(bakDir, "backups") + backupID := "20260101-rollback-test" + backupDir := filepath.Join(backupsDir, backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + + // Two backup files. + file1Backup := filepath.Join(adapterDir, "file1.txt") + file2Backup := filepath.Join(adapterDir, "file2.txt") + file1NewContent := []byte("new-content-1\n") + file2NewContent := []byte("new-content-2\n") + if err := os.WriteFile(file1Backup, file1NewContent, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file2Backup, file2NewContent, 0644); err != nil { + t.Fatal(err) + } + + h1 := sha256.Sum256(file1NewContent) + h2 := sha256.Sum256(file2NewContent) + + m := manifest.New(backupID, "linux", "testhost", "0.4.0", "quick", []string{"config"}) + m.Version = "0.4.0" + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/file1.txt", + BackupPath: "test-adapter/file1.txt", + Hash: fmt.Sprintf("sha256:%x", h1), + Size: int64(len(file1NewContent)), + Mode: 0644, + }, + { + Category: "config", + SourcePath: "~/.config/bak/file2.txt", + BackupPath: "test-adapter/file2.txt", + Hash: fmt.Sprintf("sha256:%x", h2), + Size: int64(len(file2NewContent)), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + // Pre-create targets with original content. + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + target1Path := filepath.Join(targetDir, "file1.txt") + target2Path := filepath.Join(targetDir, "file2.txt") + orig1 := []byte("original-1\n") + orig2 := []byte("original-2\n") + if err := os.WriteFile(target1Path, orig1, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target2Path, orig2, 0644); err != nil { + t.Fatal(err) + } + + baseFS := newHomeFS(home) + failingFS := &partialCopyFailingFS{ + FileSystem: baseFS, + failOnDst: target2Path, + failErr: fmt.Errorf("injected disk write error on file2"), + } + + action := &RestoreAction{ + FS: failingFS, + BackupDir: backupDir, + Force: true, + Stdout: io.Discard, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error on partial restore failure, got nil") + } + + // Target 1 must be reverted to original content via rollback. + got1, err := os.ReadFile(target1Path) + if err != nil { + t.Fatalf("read target 1: %v", err) + } + if string(got1) != string(orig1) { + t.Errorf("target 1 was not rolled back: got %q, want %q", string(got1), string(orig1)) + } + + // Target 2 must also be restored to original content, not left partially written. + got2, err := os.ReadFile(target2Path) + if err != nil { + t.Fatalf("read target 2: %v", err) + } + if string(got2) != string(orig2) { + t.Errorf("target 2 was not rolled back: got %q, want %q", string(got2), string(orig2)) + } + + // Recovery evidence must be retained in home/.bak/recovery. + recBase := filepath.Join(home, ".bak", "recovery") + entries, err := os.ReadDir(recBase) + if err != nil || len(entries) == 0 { + t.Errorf("expected recovery evidence in %s, got err: %v, entries: %d", recBase, err, len(entries)) + } +} + +type metadataFailCopyFS struct { + FileSystem + failOnDst string + failErr error + homeDir string +} + +var _ FileSystem = (*metadataFailCopyFS)(nil) + +func (m *metadataFailCopyFS) CopyFile(src, dst string) error { + if dst == m.failOnDst { + recBase := filepath.Join(m.homeDir, ".bak", "recovery") + if entries, err := os.ReadDir(recBase); err == nil && len(entries) > 0 { + metaFile := filepath.Join(recBase, entries[0].Name(), "repo", "recovery-meta.json") + if rmErr := os.Remove(metaFile); rmErr != nil && !os.IsNotExist(rmErr) { + return fmt.Errorf("remove metafile: %w", rmErr) + } + if mkErr := os.Mkdir(metaFile, 0755); mkErr != nil { + return fmt.Errorf("mkdir metafile: %w", mkErr) + } + } + return m.failErr + } + return m.FileSystem.CopyFile(src, dst) +} + +func TestRestoreAction_MetadataFailureDuringRollback_SurfacedAtActionBoundary(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + bakDir := filepath.Join(home, ".bak") + backupsDir := filepath.Join(bakDir, "backups") + backupID := "20260101-metafail-test" + backupDir := filepath.Join(backupsDir, backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + + f1Backup := filepath.Join(adapterDir, "file1.txt") + f2Backup := filepath.Join(adapterDir, "file2.txt") + if err := os.WriteFile(f1Backup, []byte("new-1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f2Backup, []byte("new-2\n"), 0644); err != nil { + t.Fatal(err) + } + + h1 := sha256.Sum256([]byte("new-1\n")) + h2 := sha256.Sum256([]byte("new-2\n")) + + m := manifest.New(backupID, "linux", "testhost", "0.4.0", "quick", []string{"config"}) + m.Version = "0.4.0" + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/file1.txt", + BackupPath: "test-adapter/file1.txt", + Hash: fmt.Sprintf("sha256:%x", h1), + Size: int64(len("new-1\n")), + Mode: 0644, + }, + { + Category: "config", + SourcePath: "~/.config/bak/file2.txt", + BackupPath: "test-adapter/file2.txt", + Hash: fmt.Sprintf("sha256:%x", h2), + Size: int64(len("new-2\n")), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + t1Path := filepath.Join(targetDir, "file1.txt") + t2Path := filepath.Join(targetDir, "file2.txt") + if err := os.WriteFile(t1Path, []byte("orig-1\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(t2Path, []byte("orig-2\n"), 0644); err != nil { + t.Fatal(err) + } + + baseFS := newHomeFS(home) + failingFS := &metadataFailCopyFS{ + FileSystem: baseFS, + failOnDst: t2Path, + failErr: fmt.Errorf("injected disk failure on file2"), + homeDir: home, + } + + var stdoutBuf strings.Builder + action := &RestoreAction{ + FS: failingFS, + BackupDir: backupDir, + Force: true, + Stdout: &stdoutBuf, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error, got nil") + } + + errMsg := err.Error() + // Must keep original apply error + if !strings.Contains(errMsg, "injected disk failure on file2") { + t.Errorf("error %q should retain original apply error", errMsg) + } + // Must surface evidence persistence failure + if !strings.Contains(errMsg, "write rollback metadata") { + t.Errorf("error %q should surface evidence persistence failure 'write rollback metadata'", errMsg) + } + + report := stdoutBuf.String() + if !strings.Contains(report, "Failed: 1") { + t.Errorf("report %q should reflect accurate failed count 1", report) + } +} + +type postFailStorageFS struct { + FileSystem +} + +var _ FileSystem = (*postFailStorageFS)(nil) + +func (p *postFailStorageFS) WriteFile(path string, data []byte, perm os.FileMode) error { + if strings.HasSuffix(path, "recovery-meta.json") && strings.Contains(string(data), `"status": "applied"`) { + return fmt.Errorf("injected post commit error") + } + return p.FileSystem.WriteFile(path, data, perm) +} + +func TestRestoreAction_RealAutomaticPostRecordingFailure(t *testing.T) { //nolint:paralleltest // shared state + home := t.TempDir() + bakDir := filepath.Join(home, ".bak") + backupsDir := filepath.Join(bakDir, "backups") + backupID := "20260101-autorecordfail" + backupDir := filepath.Join(backupsDir, backupID) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + + adapterDir := filepath.Join(backupDir, "test-adapter") + if err := os.MkdirAll(adapterDir, 0755); err != nil { + t.Fatal(err) + } + + f1Backup := filepath.Join(adapterDir, "existing.txt") + f2Backup := filepath.Join(adapterDir, "new.txt") + f1NewBytes := []byte("restored-existing\n") + f2NewBytes := []byte("restored-new\n") + if err := os.WriteFile(f1Backup, f1NewBytes, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f2Backup, f2NewBytes, 0644); err != nil { + t.Fatal(err) + } + + h1 := sha256.Sum256(f1NewBytes) + h2 := sha256.Sum256(f2NewBytes) + + m := manifest.New(backupID, "linux", "testhost", "0.4.0", "quick", []string{"config"}) + m.Version = "0.4.0" + m.AddAdapter("test-adapter", "", "~/.config/bak", []manifest.Item{ + { + Category: "config", + SourcePath: "~/.config/bak/existing.txt", + BackupPath: "test-adapter/existing.txt", + Hash: fmt.Sprintf("sha256:%x", h1), + Size: int64(len(f1NewBytes)), + Mode: 0644, + }, + { + Category: "config", + SourcePath: "~/.config/bak/new.txt", + BackupPath: "test-adapter/new.txt", + Hash: fmt.Sprintf("sha256:%x", h2), + Size: int64(len(f2NewBytes)), + Mode: 0644, + }, + }) + if err := m.Save(backupDir); err != nil { + t.Fatal(err) + } + + targetDir := filepath.Join(home, ".config", "bak") + if err := os.MkdirAll(targetDir, 0755); err != nil { + t.Fatal(err) + } + existingTarget := filepath.Join(targetDir, "existing.txt") + newTarget := filepath.Join(targetDir, "new.txt") + + origExistingBytes := []byte("orig-existing-content\n") + if err := os.WriteFile(existingTarget, origExistingBytes, 0600); err != nil { + t.Fatal(err) + } + // newTarget intentionally does not exist (pre-existing absence) + + baseFS := newHomeFS(home) + var stdoutBuf strings.Builder + action := &RestoreAction{ + FS: baseFS, + storageFS: &postFailStorageFS{FileSystem: &OSFileSystem{}}, + BackupDir: backupDir, + Force: true, + Stdout: &stdoutBuf, + Stderr: io.Discard, + } + + err := action.Run() + if err == nil { + t.Fatal("expected restore to fail during post-state recording, got nil") + } + + // Assert original error from RecordPostState is retained + if !strings.Contains(err.Error(), "injected post commit error") { + t.Errorf("error %q should retain original post recording error", err.Error()) + } + if !strings.Contains(err.Error(), "record post-restore state") { + t.Errorf("error %q should mention record post-restore state", err.Error()) + } + + // Assert existing target has original bytes and original mode restored + gotExisting, err := os.ReadFile(existingTarget) + if err != nil { + t.Fatalf("read existing target: %v", err) + } + if string(gotExisting) != string(origExistingBytes) { + t.Errorf("existing target was not rolled back: got %q, want %q", string(gotExisting), string(origExistingBytes)) + } + if !isWindows() { + fi, err := os.Stat(existingTarget) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("existing target mode = %v, want 0600", fi.Mode().Perm()) + } + } + + // Assert pre-existing absence is restored for newTarget (must not exist) + if _, err := os.Stat(newTarget); err == nil { + t.Errorf("newTarget should have been removed by rollback to restore pre-existing absence, but still exists") + } + + // Assert report reflected failed status + report := stdoutBuf.String() + if !strings.Contains(report, "Restore failed") { + t.Errorf("report %q should reflect failed restore", report) + } +} + +func TestRestoreAction_BakVersionWarning(t *testing.T) { //nolint:paralleltest // shared state + tests := []struct { + name string + backupBakVer string + injectedBakVer string + dryRun bool + wantWarning bool + warnContains []string + }{ + { + name: "matching_versions_stay_silent", + backupBakVer: "1.5.0", + injectedBakVer: "1.5.0", + dryRun: false, + wantWarning: false, + }, + { + name: "version_mismatch_warns_with_both_versions_and_id", + backupBakVer: "1.4.0", + injectedBakVer: "1.5.0", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "1.4.0", "1.5.0"}, + }, + { + name: "running_dev_warns", + backupBakVer: "1.5.0", + injectedBakVer: "dev", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "1.5.0", "dev"}, + }, + { + name: "backup_dev_warns", + backupBakVer: "dev", + injectedBakVer: "1.5.0", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "dev", "1.5.0"}, + }, + { + name: "both_dev_warns", + backupBakVer: "dev", + injectedBakVer: "dev", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "dev"}, + }, + { + name: "running_empty_unknown_warns", + backupBakVer: "1.5.0", + injectedBakVer: "", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "1.5.0", "unknown"}, + }, + { + name: "backup_empty_unknown_warns", + backupBakVer: "", + injectedBakVer: "1.5.0", + dryRun: false, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "unknown", "1.5.0"}, + }, + { + name: "dry_run_with_mismatch_still_warns", + backupBakVer: "1.4.0", + injectedBakVer: "1.5.0", + dryRun: true, + wantWarning: true, + warnContains: []string{"warning:", "20260101-120000", "1.4.0", "1.5.0"}, + }, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest + home := t.TempDir() + backupID := createCustomManifestBackupForRestore(t, home, tt.backupBakVer, manifest.ManifestVersion) + + var stdoutBuf, stderrBuf bytes.Buffer + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: filepath.Join(home, ".bak", "backups", backupID), + BakVersion: tt.injectedBakVer, + DryRun: tt.dryRun, + Force: true, + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + } + + err := action.Run() + if err != nil { + t.Fatalf("Run() unexpected error: %v", err) + } + + stderrOutput := stderrBuf.String() + stdoutOutput := stdoutBuf.String() + + // Warning must never leak to Stdout. + if strings.Contains(stdoutOutput, "warning:") { + t.Errorf("stdout should not contain warning: %q", stdoutOutput) + } + + if tt.wantWarning { + if stderrOutput == "" { + t.Fatalf("expected warning on Stderr, got empty") + } + for _, sub := range tt.warnContains { + if !strings.Contains(stderrOutput, sub) { + t.Errorf("stderr %q should contain %q", stderrOutput, sub) + } + } + } else if strings.Contains(stderrOutput, "warning:") { + t.Errorf("stderr should not contain warning, got %q", stderrOutput) + } + }) + } +} + +func TestRestoreAction_NewerSchemaVersion_FailsClosedBeforeWrite(t *testing.T) { //nolint:paralleltest // shared state + tests := []struct { + name string + schemaVersion string + wantErr bool + errContain string + }{ + { + name: "newer_minor_schema_fails_closed", + schemaVersion: "0.5.0", + wantErr: true, + errContain: "unsupported manifest schema version", + }, + { + name: "newer_major_schema_fails_closed", + schemaVersion: "1.0.0", + wantErr: true, + errContain: "unsupported manifest schema version", + }, + { + name: "current_schema_040_succeeds", + schemaVersion: "0.4.0", + wantErr: false, + }, + { + name: "older_schema_030_succeeds", + schemaVersion: "0.3.0", + wantErr: false, + }, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest + home := t.TempDir() + backupID := createCustomManifestBackupForRestore(t, home, "1.5.0", tt.schemaVersion) + + targetFile := filepath.Join(home, ".config", "bak", "config.json") + var stdoutBuf, stderrBuf bytes.Buffer + action := &RestoreAction{ + FS: newHomeFS(home), + BackupDir: filepath.Join(home, ".bak", "backups", backupID), + BakVersion: "1.5.0", + Force: true, + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + } + + err := action.Run() + if (err != nil) != tt.wantErr { + t.Fatalf("Run() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContain) + } + // Verify zero target files written + if _, statErr := os.Stat(targetFile); !os.IsNotExist(statErr) { + t.Errorf("target file should not have been written on schema rejection") + } + // Verify zero recovery side effects (recovery directory should not have any points) + recoveryPointsDir := filepath.Join(home, ".bak", "recovery") + entries, _ := os.ReadDir(recoveryPointsDir) + if len(entries) > 0 { + t.Errorf("recovery directory should not contain points, found %d", len(entries)) + } + } else { + // Succeeded: target should exist + if _, statErr := os.Stat(targetFile); statErr != nil { + t.Errorf("target file should exist: %v", statErr) + } + } + }) + } +} diff --git a/internal/actions/undo.go b/internal/actions/undo.go index 5dbd99c..1373b88 100644 --- a/internal/actions/undo.go +++ b/internal/actions/undo.go @@ -1,19 +1,31 @@ package actions import ( + "errors" "fmt" "io" - "os" "path/filepath" + "strings" ) -// UndoAction reverts the last bak operation via git revert. +// UndoAction reverts the last restore operation to its pre-restore state +// with fail-closed drift protection, and creates a revert commit in the bak repo. // All dependencies are injectable for testability. type UndoAction struct { - // Stdout receives the success message. + // FS is the file system for live target files. Defaults to &OSFileSystem{}. + FS FileSystem + + // StorageFS is the file system for the recovery repository. Defaults to &OSFileSystem{}. + StorageFS FileSystem + + // RecoveryDir optionally overrides the base recovery directory. + // When empty, defaults to filepath.Join(homeDir, ".bak", "recovery"). + RecoveryDir string + + // Stdout receives informational output. Stdout io.Writer - // HomeDir returns the user home directory. Defaults to os.UserHomeDir. + // HomeDir returns the user home directory. Defaults to FS.UserHomeDir. HomeDir func() (string, error) // BakDir builds the bak directory path from homeDir. @@ -27,35 +39,143 @@ type UndoAction struct { UndoFn func(repoPath string) error } -// Run executes the undo workflow: open the bak git repository -// and create a revert commit. -func (a *UndoAction) Run() error { +func (a *UndoAction) resolveDirectories() (homeDir, bakDir, recBase string, err error) { homeFn := a.HomeDir if homeFn == nil { - homeFn = os.UserHomeDir + homeFn = a.FS.UserHomeDir } bakFn := a.BakDir if bakFn == nil { bakFn = func(homeDir string) string { return filepath.Join(homeDir, ".bak") } } - homeDir, err := homeFn() + homeDir, err = homeFn() + if err != nil { + return "", "", "", fmt.Errorf("cannot determine home directory: %w", err) + } + + bakDir = bakFn(homeDir) + recBase = a.RecoveryDir + if recBase == "" { + recBase = filepath.Join(homeDir, ".bak", "recovery") + } + return homeDir, bakDir, recBase, nil +} + +func reportOutcome(out io.Writer, outcome rollbackOutcome, homeDir string) error { + var b strings.Builder + for _, p := range outcome.Reverted { + fmt.Fprintf(&b, "reverted: %s\n", sanitizePath(p, homeDir)) + } + for _, p := range outcome.Unresolved { + fmt.Fprintf(&b, "unresolved: %s\n", sanitizePath(p, homeDir)) + } + if _, err := fmt.Fprint(out, b.String()); err != nil { + return fmt.Errorf("write outcome: %w", err) + } + return nil +} + +func (a *UndoAction) initAction() (io.Writer, string, string, string, error) { + if a.FS == nil { + a.FS = &OSFileSystem{} + } + if a.StorageFS == nil { + a.StorageFS = &OSFileSystem{} + } + out := a.Stdout + if out == nil { + out = io.Discard + } + + homeDir, bakDir, recBase, err := a.resolveDirectories() if err != nil { - return fmt.Errorf("cannot determine home directory: %w", err) + return nil, "", "", "", err } + return out, homeDir, bakDir, recBase, nil +} - bakDir := bakFn(homeDir) +func (a *UndoAction) discoverRecoveryManager(recBase, homeDir string) (*recoveryManager, error) { + meta, err := findLatestAppliedPoint(a.StorageFS, recBase, homeDir) + if err != nil { + if errors.Is(err, ErrNoAppliedRecoveryPoint) { + return nil, fmt.Errorf("no applied recovery point found: %w", err) + } + return nil, fmt.Errorf("discover recovery point: %w", err) + } + + if meta.PointID == "" || meta.PointID != filepath.Base(meta.PointID) || meta.PointID == "." || meta.PointID == ".." { + return nil, fmt.Errorf("invalid recovery point id: %s", meta.PointID) + } + + pointDir := filepath.Join(recBase, meta.PointID) + return &recoveryManager{ + FS: a.FS, + StorageFS: a.StorageFS, + HomeDir: homeDir, + BackupID: meta.BackupID, + PointID: meta.PointID, + PointDir: pointDir, + RepoDir: filepath.Join(pointDir, "repo"), + RecoveryDir: recBase, + Meta: *meta, + }, nil +} + +func (a *UndoAction) applyUndo(recMgr *recoveryManager, out io.Writer, homeDir string) error { + outcome, undoErr := recMgr.UndoTargets() + repErr := reportOutcome(out, outcome, homeDir) + if undoErr != nil || len(outcome.Unresolved) > 0 { + if undoErr == nil { + undoErr = fmt.Errorf("unresolved targets remain") + } + if repErr != nil { + return errors.Join(fmt.Errorf("undo targets: %w", undoErr), repErr) + } + return fmt.Errorf("undo targets: %w", undoErr) + } + if repErr != nil { + return repErr + } + return nil +} + +// Run executes the undo workflow: resolves the home directory, discovers the +// latest applied recovery point, verifies targets against post-restore state +// for drift BEFORE any mutation or commit, projects pre-state back to target +// files, records reverted and unresolved targets with sanitized paths, and +// creates a revert commit in the bak repository. +func (a *UndoAction) Run() error { + out, homeDir, bakDir, recBase, err := a.initAction() + if err != nil { + return err + } if a.IsRepo != nil && !a.IsRepo(bakDir) { return fmt.Errorf("no bak repository found β€” run 'bak backup' first") } + recMgr, err := a.discoverRecoveryManager(recBase, homeDir) + if err != nil { + return err + } + + if err := recMgr.CheckUndoDrift(); err != nil { + return fmt.Errorf("check undo drift: %w", err) + } + + if err := a.applyUndo(recMgr, out, homeDir); err != nil { + return err + } + if a.UndoFn != nil { if err := a.UndoFn(bakDir); err != nil { return fmt.Errorf("undo failed: %w", err) } } - _, _ = fmt.Fprintln(a.Stdout, "βœ… Reverted to previous state") + if _, err := fmt.Fprintln(out, "βœ… Reverted to previous state"); err != nil { + return fmt.Errorf("write confirmation: %w", err) + } return nil } diff --git a/internal/actions/undo_test.go b/internal/actions/undo_test.go index 7d23ea4..e7a504e 100644 --- a/internal/actions/undo_test.go +++ b/internal/actions/undo_test.go @@ -1,22 +1,31 @@ package actions import ( + "encoding/json" "errors" + "os" + "path/filepath" "strings" "testing" + + "github.com/danielxxomg/bak-cli/internal/paths" ) -func TestUndoAction_Success(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending +func TestUndoAction_Success(t *testing.T) { //nolint:paralleltest // shared state + rm, home, t1, t2 := setupUndoTestScenario(t) var out strings.Builder undoCalled := false action := &UndoAction{ - Stdout: &out, + FS: rm.FS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, HomeDir: func() (string, error) { - return "/home/test", nil + return home, nil }, BakDir: func(homeDir string) string { - return homeDir + "/.bak" + return filepath.Join(homeDir, ".bak") }, IsRepo: func(path string) bool { return true @@ -35,13 +44,226 @@ func TestUndoAction_Success(t *testing.T) { //nolint:paralleltest // not yet par if !undoCalled { t.Error("UndoFn was not called") } + + // Target 1 was pre="orig1\n", post="post1\n". It should now be "orig1\n". + content1, err := os.ReadFile(t1) + if err != nil { + t.Fatalf("read t1: %v", err) + } + if string(content1) != "orig1\n" { + t.Errorf("t1 not restored to pre-state, got %q", string(content1)) + } + + // Target 2 was pre-absent, post="post2\n". It should now be removed. + if _, err := os.Stat(t2); !errors.Is(err, os.ErrNotExist) { + t.Errorf("t2 should be removed, got err: %v", err) + } + output := out.String() - if !strings.Contains(output, "Reverted") { + if !strings.Contains(output, "Reverted to previous state") { t.Errorf("output should confirm revert: %q", output) } + if !strings.Contains(output, "reverted: ~/file1.txt") { + t.Errorf("output should mention reverted file1.txt: %q", output) + } + if !strings.Contains(output, "reverted: ~/file2.txt") { + t.Errorf("output should mention reverted file2.txt: %q", output) + } } -func TestUndoAction_NotARepo(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending +func TestUndoAction_EachDriftClass_AbortsZeroWrite(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(t *testing.T, rm *recoveryManager, home, t1, t2 string) + expectErrFrag string + skipWindows bool + }{ + { + name: "content drift on t1 rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + if err := os.WriteFile(t1, []byte("user-drift-content\n"), 0644); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "content mismatch", + }, + { + name: "mode drift on t1 rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + if err := os.Chmod(t1, 0755); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "mode mismatch", + skipWindows: true, + }, + { + name: "deletion drift on t1 rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "missing", + }, + { + name: "unexpected existence drift when post was absent rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + cleanT2 := paths.CanonicalPath(t2) + post := rm.Meta.TargetPostMap[cleanT2] + post.Existed = false + rm.Meta.TargetPostMap[cleanT2] = post + metaBytes, err := json.MarshalIndent(rm.Meta, "", " ") + if err != nil { + t.Fatal(err) + } + metaPath := filepath.Join(rm.RepoDir, "recovery-meta.json") + if err := os.WriteFile(metaPath, metaBytes, 0600); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "expected ~/file2.txt to be absent", + }, + { + name: "symlink drift on t1 rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + other := filepath.Join(home, "other.txt") + if err := os.WriteFile(other, []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(other, t1); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "symlink", + skipWindows: true, + }, + { + name: "directory drift on t1 rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + if err := os.Remove(t1); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(t1, 0755); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "directory", + }, + { + name: "tampered pre snapshot rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file1.txt")) + if err := os.WriteFile(preSnap, []byte("tampered\n"), 0600); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "checksum mismatch", + }, + { + name: "missing pre snapshot rejected", + mutate: func(t *testing.T, rm *recoveryManager, home, t1, t2 string) { + t.Helper() + preSnap := filepath.Join(rm.RepoDir, "snapshots", "pre", opaquePayloadName("file1.txt")) + if err := os.Remove(preSnap); err != nil { + t.Fatal(err) + } + }, + expectErrFrag: "pre-snapshot", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.skipWindows && isWindows() { + t.Skip("skipping on Windows") + } + rm, home, t1, t2 := setupUndoTestScenario(t) + tt.mutate(t, rm, home, t1, t2) + + // Record file stats / existence before undo + var t1ExistsBefore bool + var t1ContentBefore []byte + if info, err := os.Stat(t1); err == nil && !info.IsDir() { + t1ExistsBefore = true + t1ContentBefore, _ = os.ReadFile(t1) + } + t2ContentBefore, _ := os.ReadFile(t2) + + undoCalled := false + var out strings.Builder + action := &UndoAction{ + FS: rm.FS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, + HomeDir: func() (string, error) { return home, nil }, + BakDir: func(string) string { return filepath.Join(home, ".bak") }, + IsRepo: func(string) bool { return true }, + UndoFn: func(string) error { + undoCalled = true + return nil + }, + } + + err := action.Run() + if err == nil { + t.Fatalf("expected drift error for %q, got nil", tt.name) + } + if !strings.Contains(err.Error(), tt.expectErrFrag) { + t.Errorf("error %q should contain %q", err.Error(), tt.expectErrFrag) + } + if undoCalled { + t.Errorf("UndoFn must not be called when drift is detected") + } + + // Assert ZERO writes on targets after error: + if t1ExistsBefore { + t1ContentAfter, _ := os.ReadFile(t1) + if string(t1ContentAfter) != string(t1ContentBefore) { + t.Errorf("t1 was modified despite drift error: before %q, after %q", string(t1ContentBefore), string(t1ContentAfter)) + } + } + t2ContentAfter, _ := os.ReadFile(t2) + if string(t2ContentAfter) != string(t2ContentBefore) { + t.Errorf("t2 was modified despite drift error: before %q, after %q", string(t2ContentBefore), string(t2ContentAfter)) + } + }) + } +} + +func TestUndoAction_NoneFound_FailsClosed(t *testing.T) { //nolint:paralleltest // shared state + var out strings.Builder + home := t.TempDir() + action := &UndoAction{ + Stdout: &out, + HomeDir: func() (string, error) { + return home, nil + }, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error when no applied recovery point found, got nil") + } + if !errors.Is(err, ErrNoAppliedRecoveryPoint) && !strings.Contains(err.Error(), "no applied recovery point") { + t.Fatalf("expected ErrNoAppliedRecoveryPoint or mention, got: %v", err) + } +} + +func TestUndoAction_NotARepo(t *testing.T) { //nolint:paralleltest // shared state var out strings.Builder action := &UndoAction{ @@ -50,7 +272,7 @@ func TestUndoAction_NotARepo(t *testing.T) { //nolint:paralleltest // not yet pa return "/home/test", nil }, BakDir: func(homeDir string) string { - return homeDir + "/.bak" + return filepath.Join(homeDir, ".bak") }, IsRepo: func(path string) bool { return false @@ -66,22 +288,26 @@ func TestUndoAction_NotARepo(t *testing.T) { //nolint:paralleltest // not yet pa } } -func TestUndoAction_UndoFails(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending +func TestUndoAction_UndoFails(t *testing.T) { //nolint:paralleltest // shared state + rm, home, _, _ := setupUndoTestScenario(t) var out strings.Builder action := &UndoAction{ - Stdout: &out, + FS: rm.FS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, HomeDir: func() (string, error) { - return "/home/test", nil + return home, nil }, BakDir: func(homeDir string) string { - return homeDir + "/.bak" + return filepath.Join(homeDir, ".bak") }, IsRepo: func(path string) bool { return true }, UndoFn: func(repoPath string) error { - return errors.New("revert conflict") + return errors.New("revert conflict in bak repo") }, } @@ -94,16 +320,13 @@ func TestUndoAction_UndoFails(t *testing.T) { //nolint:paralleltest // not yet p } } -func TestUndoAction_DefaultHomeDir(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending - // When HomeDir is nil, Run() defaults to os.UserHomeDir. - // This exercises the default path without needing real git repo. +func TestUndoAction_DefaultHomeDir(t *testing.T) { //nolint:paralleltest // shared state var out strings.Builder action := &UndoAction{ Stdout: &out, - // HomeDir is nil β€” exercises default. IsRepo: func(path string) bool { - return false // short-circuit: not a repo error before reaching undo + return false }, } @@ -116,8 +339,7 @@ func TestUndoAction_DefaultHomeDir(t *testing.T) { //nolint:paralleltest // not } } -func TestUndoAction_DefaultBakDir(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending - // When BakDir is nil, Run() defaults to filepath.Join(homeDir, ".bak"). +func TestUndoAction_DefaultBakDir(t *testing.T) { //nolint:paralleltest // shared state var out strings.Builder action := &UndoAction{ @@ -125,7 +347,6 @@ func TestUndoAction_DefaultBakDir(t *testing.T) { //nolint:paralleltest // not y HomeDir: func() (string, error) { return "/home/test", nil }, - // BakDir is nil β€” exercises default. IsRepo: func(path string) bool { return false }, @@ -140,15 +361,17 @@ func TestUndoAction_DefaultBakDir(t *testing.T) { //nolint:paralleltest // not y } } -func TestUndoAction_NilIsRepo_NilUndoFn(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending - // When both IsRepo and UndoFn are nil, Run() skips the checks - // and prints the success message. +func TestUndoAction_NilIsRepo_NilUndoFn(t *testing.T) { //nolint:paralleltest // shared state + rm, home, t1, t2 := setupUndoTestScenario(t) var out strings.Builder action := &UndoAction{ - Stdout: &out, + FS: rm.FS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, HomeDir: func() (string, error) { - return "/home/test", nil + return home, nil }, } @@ -158,12 +381,21 @@ func TestUndoAction_NilIsRepo_NilUndoFn(t *testing.T) { //nolint:paralleltest // } output := out.String() - if !strings.Contains(output, "Reverted") { + if !strings.Contains(output, "Reverted to previous state") { t.Errorf("output should confirm revert even without IsRepo/UndoFn: %q", output) } + + // Verify target 1 restored and target 2 removed + c1, _ := os.ReadFile(t1) + if string(c1) != "orig1\n" { + t.Errorf("expected t1 to be restored to orig1, got %q", string(c1)) + } + if _, err := os.Stat(t2); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected t2 to be removed, got err %v", err) + } } -func TestUndoAction_HomeDirError(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending +func TestUndoAction_HomeDirError(t *testing.T) { //nolint:paralleltest // shared state var out strings.Builder action := &UndoAction{ @@ -181,3 +413,113 @@ func TestUndoAction_HomeDirError(t *testing.T) { //nolint:paralleltest // not ye t.Errorf("error should mention home directory: %v", err) } } + +func TestUndoAction_PartialAccounting(t *testing.T) { //nolint:paralleltest // shared state + rm, home, _, t2 := setupUndoTestScenario(t) + var out strings.Builder + + // Create an unexpected directory at t2. Since t2 was pre-absent, + // rollbackTarget will refuse to remove an unexpected directory. + // But first, to pass CheckUndoDrift, t2 must match post-state. + // We can tamper with rm.Meta or make pre-state invalid for one target after drift check. + // Better yet, remove the pre-snapshot file for file1.txt, but keep it for file2.txt? + // Wait, CheckUndoDrift checks pre-snapshot presence for file1.txt. + // To test partial failure during UndoTargets: + // If pre-snapshot for file1.txt has invalid permissions or is corrupted after CheckUndoDrift? + // Or we can corrupt file1.txt pre snapshot right between CheckUndoDrift and UndoTargets? + // But in a black-box test on Action.Run(), CheckUndoDrift runs immediately before UndoTargets. + // Wait! If t2 pre-state was absent (so rollbackTarget tries to remove t2), + // what if t2 removal fails? E.g. t2 is read-only directory or file on a filesystem that errors? + // Or we can inject a custom FileSystem for FS! + // Let's create an fs wrapper where Remove(t2) fails! + partialFS := &removeFailFS{ + FileSystem: rm.FS, + failPath: t2, + } + + action := &UndoAction{ + FS: partialFS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, + HomeDir: func() (string, error) { + return home, nil + }, + BakDir: func(string) string { + return filepath.Join(home, ".bak") + }, + IsRepo: func(string) bool { + return true + }, + UndoFn: func(string) error { + return nil + }, + } + + err := action.Run() + if err == nil { + t.Fatal("expected error on partial undo target failure, got nil") + } + + outStr := out.String() + // Should report reverted file1.txt and unresolved file2.txt + if !strings.Contains(outStr, "reverted: ~/file1.txt") { + t.Errorf("stdout should report reverted file1.txt: %q", outStr) + } + if !strings.Contains(outStr, "unresolved: ~/file2.txt") { + t.Errorf("stdout should report unresolved file2.txt: %q", outStr) + } +} + +type removeFailFS struct { + FileSystem + failPath string +} + +var _ FileSystem = (*removeFailFS)(nil) + +func (r *removeFailFS) Remove(name string) error { + if paths.CanonicalPath(name) == paths.CanonicalPath(r.failPath) { + return errors.New("simulated remove failure") + } + return r.FileSystem.Remove(name) +} + +func TestUndoAction_Privacy(t *testing.T) { //nolint:paralleltest // shared state + rm, home, t1, _ := setupUndoTestScenario(t) + // Mutate t1 to trigger drift + if err := os.WriteFile(t1, []byte("drifted\n"), 0644); err != nil { + t.Fatal(err) + } + + var out strings.Builder + action := &UndoAction{ + FS: rm.FS, + StorageFS: rm.StorageFS, + RecoveryDir: rm.RecoveryDir, + Stdout: &out, + HomeDir: func() (string, error) { + return home, nil + }, + BakDir: func(string) string { + return filepath.Join(home, ".bak") + }, + IsRepo: func(string) bool { + return true + }, + } + + err := action.Run() + if err == nil { + t.Fatal("expected drift error, got nil") + } + + // Verify error does NOT leak raw home directory + if strings.Contains(err.Error(), home) { + t.Errorf("error leaked raw home directory %q: %v", home, err) + } + // Verify output does NOT leak raw home directory + if strings.Contains(out.String(), home) { + t.Errorf("stdout leaked raw home directory %q: %q", home, out.String()) + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 673957a..9f8c11d 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" @@ -19,6 +20,92 @@ import ( // ManifestVersion is the current schema version written by this tool. const ManifestVersion = "0.4.0" +// parseSemver splits a version into [major, minor, patch] integers and optional prerelease string. +func parseSemver(v string) ([]int, string, error) { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + v = strings.TrimPrefix(v, "V") + if v == "" { + return nil, "", fmt.Errorf("version string is empty") + } + + // Strip build metadata (+...) + main, _, _ := strings.Cut(v, "+") + main, prerelease, _ := strings.Cut(main, "-") + + parts := strings.Split(main, ".") + if len(parts) > 3 { + return nil, "", fmt.Errorf("too many parts in version %q", v) + } + + nums := make([]int, 3) + for i, p := range parts { + if p == "" { + return nil, "", fmt.Errorf("empty part in version %q", v) + } + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return nil, "", fmt.Errorf("invalid numeric version part %q in %q", p, v) + } + nums[i] = n + } + return nums, prerelease, nil +} + +// CompareVersions compares two semver-like version strings v1 and v2. +// It returns -1 if v1 < v2, 0 if v1 == v2, and 1 if v1 > v2. +func CompareVersions(v1, v2 string) (int, error) { + nums1, pre1, err := parseSemver(v1) + if err != nil { + return 0, err + } + nums2, pre2, err := parseSemver(v2) + if err != nil { + return 0, err + } + + for i := 0; i < 3; i++ { + if nums1[i] < nums2[i] { + return -1, nil + } + if nums1[i] > nums2[i] { + return 1, nil + } + } + + // Numbers are equal. Check prerelease. + // Semver rule: 1.0.0-alpha < 1.0.0 (release version has higher precedence than prerelease) + if pre1 != "" && pre2 == "" { + return -1, nil + } + if pre1 == "" && pre2 != "" { + return 1, nil + } + if pre1 < pre2 { + return -1, nil + } + if pre1 > pre2 { + return 1, nil + } + return 0, nil +} + +// ValidateSchemaVersion checks that a manifest schema version is known and supported +// by this version of bak (i.e. not newer than ManifestVersion). +func ValidateSchemaVersion(version string) error { + if version == "" { + return fmt.Errorf("manifest version is empty") + } + cmp, err := CompareVersions(version, ManifestVersion) + if err != nil { + return fmt.Errorf("unsupported manifest schema version %q: %w", version, err) + } + if cmp > 0 { + return fmt.Errorf("unsupported manifest schema version %s (maximum supported is %s): upgrade bak to restore this backup", version, ManifestVersion) + } + return nil +} + // AdapterManifest records the items backed up by a single adapter. type AdapterManifest struct { VersionDetected string `json:"version_detected,omitempty"` @@ -138,6 +225,9 @@ func (m *Manifest) Validate(backupDir string, progressFn func(string)) error { if m.Version == "" { return fmt.Errorf("manifest version is empty") } + if err := ValidateSchemaVersion(m.Version); err != nil { + return err + } if len(m.Adapters) == 0 { return fmt.Errorf("manifest contains no adapters") } @@ -182,19 +272,21 @@ func (m *Manifest) recount() { } // hashFile computes the SHA-256 hex digest of a file. -func hashFile(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", fmt.Errorf("open: %w", err) +func hashFile(path string) (res string, err error) { + f, openErr := os.Open(path) + if openErr != nil { + return "", fmt.Errorf("open: %w", openErr) } - defer func() { _ = f.Close() }() + defer func() { + if closeErr := f.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close: %w", closeErr) + res = "" + } + }() h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", fmt.Errorf("hash: %w", err) - } - if err := f.Close(); err != nil { - return "", fmt.Errorf("close: %w", err) + if _, copyErr := io.Copy(h, f); copyErr != nil { + return "", fmt.Errorf("hash: %w", copyErr) } return fmt.Sprintf("sha256:%x", h.Sum(nil)), nil } diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index 7f0e429..6910a20 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -1,8 +1,12 @@ package manifest import ( + "bytes" + "crypto/sha256" + "fmt" "os" "path/filepath" + "strings" "testing" "time" ) @@ -264,20 +268,11 @@ func TestManifest_JSON_PlaintextOmitsEncryption(t *testing.T) { //nolint:paralle if err != nil { t.Fatalf("read raw manifest: %v", err) } - if bytesContains(raw, `"encryption"`) { + if bytes.Contains(raw, []byte(`"encryption"`)) { t.Error("raw JSON contains 'encryption' key for plaintext manifest") } } -func bytesContains(data []byte, substr string) bool { - for i := 0; i <= len(data)-len(substr); i++ { - if string(data[i:i+len(substr)]) == substr { - return true - } - } - return false -} - func TestSetEncryption_NilSaltNonce(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := New("nil-test", "linux", "box", "0.1.0", "full", []string{"config"}) @@ -392,3 +387,137 @@ func TestManifest_LoadV030WithoutMode(t *testing.T) { //nolint:paralleltest // s t.Errorf("Validate 0.3.0 manifest: %v", err) } } + +func TestCompareVersions(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending + tests := []struct { + name string + v1 string + v2 string + want int + wantErr bool + }{ + {"equal_same", "0.4.0", "0.4.0", 0, false}, + {"older_minor", "0.3.0", "0.4.0", -1, false}, + {"newer_minor", "0.5.0", "0.4.0", 1, false}, + {"older_major", "0.4.0", "1.0.0", -1, false}, + {"newer_major", "1.0.0", "0.4.0", 1, false}, + {"newer_patch", "0.4.1", "0.4.0", 1, false}, + {"older_patch", "0.4.0", "0.4.1", -1, false}, + {"two_components", "0.4", "0.4.0", 0, false}, + {"multi_digit_newer", "0.10.0", "0.4.0", 1, false}, + {"multi_digit_older", "0.4.0", "0.10.0", -1, false}, + {"v_prefix", "v0.4.0", "0.4.0", 0, false}, + {"prerelease_older", "0.4.0-rc1", "0.4.0", -1, false}, + {"empty_v1", "", "0.4.0", 0, true}, + {"empty_v2", "0.4.0", "", 0, true}, + {"invalid_non_numeric", "abc", "0.4.0", 0, true}, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest + got, err := CompareVersions(tt.v1, tt.v2) + if (err != nil) != tt.wantErr { + t.Fatalf("CompareVersions(%q, %q) error = %v, wantErr %v", tt.v1, tt.v2, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("CompareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, got, tt.want) + } + }) + } +} + +func TestValidateSchemaVersion(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending + tests := []struct { + name string + version string + wantErr bool + errContain string + }{ + {"current_schema_040", "0.4.0", false, ""}, + {"older_schema_030", "0.3.0", false, ""}, + {"older_schema_010", "0.1.0", false, ""}, + {"newer_minor_050", "0.5.0", true, "unsupported manifest schema version 0.5.0 (maximum supported is 0.4.0): upgrade bak"}, + {"newer_major_100", "1.0.0", true, "unsupported manifest schema version 1.0.0 (maximum supported is 0.4.0): upgrade bak"}, + {"empty_version", "", true, "manifest version is empty"}, + {"invalid_version", "not-a-version", true, "unsupported manifest schema version"}, + } + + for _, tt := range tests { //nolint:paralleltest + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest + err := ValidateSchemaVersion(tt.version) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateSchemaVersion(%q) error = %v, wantErr %v", tt.version, err, tt.wantErr) + } + if tt.wantErr && tt.errContain != "" && (err == nil || !strings.Contains(err.Error(), tt.errContain)) { + t.Errorf("ValidateSchemaVersion(%q) error = %v, want to contain %q", tt.version, err, tt.errContain) + } + }) + } +} + +func TestValidate_NewerSchemaVersion(t *testing.T) { //nolint:paralleltest // not yet parallelized β€” shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := &Manifest{ + Version: "0.5.0", + Adapters: map[string]AdapterManifest{"test": {}}, + } + err := m.Validate(".", nil) + if err == nil { + t.Fatal("Validate() expected error for newer schema 0.5.0, got nil") + } + if !strings.Contains(err.Error(), "unsupported manifest schema version") { + t.Errorf("Validate() error %q should mention unsupported manifest schema version", err.Error()) + } +} + +func TestHashFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + validFile := filepath.Join(dir, "test.txt") + content := []byte("hello world\n") + if err := os.WriteFile(validFile, content, 0644); err != nil { + t.Fatalf("write test file: %v", err) + } + + h := sha256.Sum256(content) + expectedHash := fmt.Sprintf("sha256:%x", h) + + tests := []struct { + name string + path string + wantHash string + wantErr bool + errSnippet string + }{ + { + name: "valid_file", + path: validFile, + wantHash: expectedHash, + wantErr: false, + }, + { + name: "nonexistent_file", + path: filepath.Join(dir, "missing.txt"), + wantErr: true, + errSnippet: "open:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := hashFile(tt.path) + if (err != nil) != tt.wantErr { + t.Fatalf("hashFile(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr) + } + if tt.wantErr { + if !strings.Contains(err.Error(), tt.errSnippet) { + t.Errorf("hashFile error %q should contain %q", err.Error(), tt.errSnippet) + } + return + } + if got != tt.wantHash { + t.Errorf("hashFile(%q) = %q, want %q", tt.path, got, tt.wantHash) + } + }) + } +} diff --git a/internal/tui/screens/restore_picker.go b/internal/tui/screens/restore_picker.go new file mode 100644 index 0000000..4578840 --- /dev/null +++ b/internal/tui/screens/restore_picker.go @@ -0,0 +1,121 @@ +package screens + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/danielxxomg/bak-cli/internal/tui/components" + "github.com/danielxxomg/bak-cli/internal/tui/styles" +) + +const keyEsc = "esc" + +// RestorePickerModel is the bubbletea model for the interactive restore picker. +// It lists available backups and lets the user select one with ↑/↓ + Enter. +// q/Esc cancels. +type RestorePickerModel struct { + // Backups holds the list of available backups to display. + Backups []BackupInfo + + // Cursor is the index of the currently highlighted backup. + Cursor int + + // Quitting indicates whether the picker was dismissed without selection. + Quitting bool + + // Confirmed indicates whether the user confirmed a selection with Enter. + Confirmed bool + + // Width is the current terminal width in columns. + Width int + + // Height is the current terminal height in rows. + Height int +} + +// Init initializes the RestorePickerModel. Returns nil command. +func (m RestorePickerModel) Init() tea.Cmd { + return nil +} + +// Update handles messages for the picker: resize events and navigation keystrokes. +func (m RestorePickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + return m, nil + + case tea.KeyPressMsg: + switch msg.String() { + case "q", keyEsc, "ctrl+c": + m.Quitting = true + return m, tea.Quit + + case "up", "k": + if m.Cursor > 0 { + m.Cursor-- + } + + case "down", "j": + if m.Cursor < len(m.Backups)-1 { + m.Cursor++ + } + + case keyEnter: + if len(m.Backups) > 0 { + m.Confirmed = true + return m, tea.Quit + } + } + } + + return m, nil +} + +// View renders the restore picker interface. +func (m RestorePickerModel) View() tea.View { + if m.Quitting { + return tea.NewView("") + } + + // Guard against terminals below the minimum usable size. + if m.Width > 0 && m.Height > 0 && (m.Width < 20 || m.Height < 10) { + return tea.NewView(styles.HelpStyle.Render("Terminal too small (min 20x10)")) + } + + var b strings.Builder + + b.WriteString(styles.TitleStyle.Render("Select backup to restore")) + b.WriteString("\n\n") + + if len(m.Backups) == 0 { + b.WriteString(styles.HelpStyle.Render("No backups found.")) + b.WriteString("\n\n") + } else { + for i, bk := range m.Backups { + label := fmt.Sprintf("%-18s %-22s %s", bk.ID, bk.Date, bk.Size) + b.WriteString(components.RenderRadio(label, i == m.Cursor, i == m.Cursor)) + b.WriteByte('\n') + } + } + + b.WriteString("\n") + b.WriteString(components.RenderHelp([]components.HelpKey{ + {Key: keyArrowUpDown, Desc: keyNavigate}, + {Key: keyEnter, Desc: keySelect}, + {Key: "q/" + keyEsc, Desc: "cancel"}, + })) + + return tea.NewView(b.String()) +} + +// SelectedID returns the selected backup ID, or empty string if none. +func (m RestorePickerModel) SelectedID() string { + if !m.Confirmed || len(m.Backups) == 0 || m.Cursor >= len(m.Backups) || m.Cursor < 0 { + return "" + } + return m.Backups[m.Cursor].ID +} diff --git a/internal/tui/screens/restore_picker_test.go b/internal/tui/screens/restore_picker_test.go new file mode 100644 index 0000000..0d5b2af --- /dev/null +++ b/internal/tui/screens/restore_picker_test.go @@ -0,0 +1,374 @@ +package screens + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// TestRestorePicker_SelectsBackup verifies Enter sets SelectedID. +func TestRestorePicker_SelectsBackup(t *testing.T) { //nolint:paralleltest // pure model state transition test + backups := []BackupInfo{ + {ID: "20260617-120000", Date: "2026-06-17", Size: "1.0 MB"}, + {ID: "20260618-090000", Date: "2026-06-18", Size: "2.5 MB"}, + } + m := RestorePickerModel{Backups: backups, Cursor: 1} + + newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = newM.(RestorePickerModel) + + if !m.Confirmed { + t.Error("Enter should set Confirmed=true") + } + if m.SelectedID() != "20260618-090000" { + t.Errorf("SelectedID = %q, want %q", m.SelectedID(), "20260618-090000") + } +} + +// TestRestorePicker_EmptyList verifies empty backups list handled. +func TestRestorePicker_EmptyList(t *testing.T) { //nolint:paralleltest // pure model state transition test + m := RestorePickerModel{Backups: nil} + + view := m.View() + if !strings.Contains(view.Content, "No backups") { + t.Error("empty list should show 'No backups' message") + } + + newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = newM.(RestorePickerModel) + if m.Confirmed { + t.Error("Enter on empty list should not confirm") + } +} + +// TestRestorePicker_Cancel verifies q/Esc cancels. +func TestRestorePicker_Cancel(t *testing.T) { //nolint:paralleltest // pure model state transition test + backups := []BackupInfo{ + {ID: "20260617-120000", Date: "2026-06-17", Size: "1.0 MB"}, + } + m := RestorePickerModel{Backups: backups, Cursor: 0} + newM, _ := m.Update(tea.KeyPressMsg{Code: 'q'}) + m = newM.(RestorePickerModel) + + if m.Confirmed { + t.Error("q should not confirm selection") + } + if m.SelectedID() != "" { + t.Errorf("SelectedID after q = %q, want empty", m.SelectedID()) + } +} + +// TestRestorePicker_CursorBounds verifies cursor cannot go out of bounds. +func TestRestorePicker_CursorBounds(t *testing.T) { //nolint:paralleltest // pure model state transition test + backups := []BackupInfo{ + {ID: "a", Date: "d1", Size: "1"}, + {ID: "b", Date: "d2", Size: "2"}, + } + + m := RestorePickerModel{Backups: backups, Cursor: 0} + newM, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + m = newM.(RestorePickerModel) + if m.Cursor != 0 { + t.Errorf("cursor should stay 0, got %d", m.Cursor) + } + + m2 := RestorePickerModel{Backups: backups, Cursor: 1} + newM2, _ := m2.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m2 = newM2.(RestorePickerModel) + if m2.Cursor != 1 { + t.Errorf("cursor should stay 1, got %d", m2.Cursor) + } +} + +// TestRestorePicker_NarrowTerminal shows "too small" message. +func TestRestorePicker_NarrowTerminal(t *testing.T) { //nolint:paralleltest // pure model state transition test + m := RestorePickerModel{Width: 19, Height: 9} + view := m.View() + + if !strings.Contains(view.Content, "too small") { + t.Error("narrow terminal should show 'too small' message") + } +} + +func TestRestorePickerModel_TableDriven_Update(t *testing.T) { //nolint:paralleltest // pure model state transition test + sampleBackups := []BackupInfo{ + {ID: "20260101-100000", Date: "2026-01-01", Size: "1.2 MB"}, + {ID: "20260102-100000", Date: "2026-01-02", Size: "2.4 MB"}, + {ID: "20260103-100000", Date: "2026-01-03", Size: "3.6 MB"}, + } + + tests := []struct { + name string + initial RestorePickerModel + msg tea.Msg + wantCursor int + wantConfirmed bool + wantQuitting bool + wantCmdQuit bool + wantWidth int + wantHeight int + }{ + { + name: "window_size_msg_updates_dimensions", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.WindowSizeMsg{Width: 80, Height: 24}, + wantCursor: 0, + wantWidth: 80, + wantHeight: 24, + }, + { + name: "cursor_down_increments", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.KeyPressMsg{Code: tea.KeyDown}, + wantCursor: 1, + }, + { + name: "cursor_down_j_increments", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 1}, + msg: tea.KeyPressMsg{Code: 'j'}, + wantCursor: 2, + }, + { + name: "cursor_down_at_bottom_stays_clamped", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 2}, + msg: tea.KeyPressMsg{Code: tea.KeyDown}, + wantCursor: 2, + }, + { + name: "cursor_up_decrements", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 2}, + msg: tea.KeyPressMsg{Code: tea.KeyUp}, + wantCursor: 1, + }, + { + name: "cursor_up_k_decrements", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 1}, + msg: tea.KeyPressMsg{Code: 'k'}, + wantCursor: 0, + }, + { + name: "cursor_up_at_top_stays_clamped", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.KeyPressMsg{Code: tea.KeyUp}, + wantCursor: 0, + }, + { + name: "enter_with_items_confirms_and_quits", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 1}, + msg: tea.KeyPressMsg{Code: tea.KeyEnter}, + wantCursor: 1, + wantConfirmed: true, + wantCmdQuit: true, + }, + { + name: "enter_with_empty_items_does_not_confirm", + initial: RestorePickerModel{Backups: []BackupInfo{}, Cursor: 0}, + msg: tea.KeyPressMsg{Code: tea.KeyEnter}, + wantCursor: 0, + wantConfirmed: false, + }, + { + name: "enter_with_nil_items_does_not_confirm", + initial: RestorePickerModel{Backups: nil, Cursor: 0}, + msg: tea.KeyPressMsg{Code: tea.KeyEnter}, + wantCursor: 0, + wantConfirmed: false, + }, + { + name: "quit_with_q", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.KeyPressMsg{Code: 'q'}, + wantQuitting: true, + wantCmdQuit: true, + }, + { + name: "quit_with_esc", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.KeyPressMsg{Code: tea.KeyEsc}, + wantQuitting: true, + wantCmdQuit: true, + }, + { + name: "quit_with_ctrl_c", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 0}, + msg: tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}, + wantQuitting: true, + wantCmdQuit: true, + }, + { + name: "unhandled_key_ignored", + initial: RestorePickerModel{Backups: sampleBackups, Cursor: 1}, + msg: tea.KeyPressMsg{Code: 'x'}, + wantCursor: 1, + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + m := tt.initial + if m.Init() != nil { + t.Error("Init() should return nil") + } + + updated, cmd := m.Update(tt.msg) + gotModel, ok := updated.(RestorePickerModel) + if !ok { + t.Fatalf("Update() returned type %T, want RestorePickerModel", updated) + } + + if gotModel.Cursor != tt.wantCursor { + t.Errorf("Cursor = %d, want %d", gotModel.Cursor, tt.wantCursor) + } + if gotModel.Confirmed != tt.wantConfirmed { + t.Errorf("Confirmed = %v, want %v", gotModel.Confirmed, tt.wantConfirmed) + } + if gotModel.Quitting != tt.wantQuitting { + t.Errorf("Quitting = %v, want %v", gotModel.Quitting, tt.wantQuitting) + } + if tt.wantCmdQuit && cmd == nil { + t.Error("expected tea.Quit command, got nil") + } + if !tt.wantCmdQuit && cmd != nil { + t.Errorf("expected nil cmd, got %v", cmd) + } + if tt.wantWidth != 0 && gotModel.Width != tt.wantWidth { + t.Errorf("Width = %d, want %d", gotModel.Width, tt.wantWidth) + } + if tt.wantHeight != 0 && gotModel.Height != tt.wantHeight { + t.Errorf("Height = %d, want %d", gotModel.Height, tt.wantHeight) + } + }) + } +} + +func TestRestorePickerModel_TableDriven_ViewAndSelection(t *testing.T) { //nolint:paralleltest // pure model state transition test + sampleBackups := []BackupInfo{ + {ID: "20260101-100000", Date: "2026-01-01", Size: "1.2 MB"}, + {ID: "20260102-100000", Date: "2026-01-02", Size: "2.4 MB"}, + } + + tests := []struct { + name string + model RestorePickerModel + wantViewSub string + wantViewEmpty bool + wantSelectedID string + }{ + { + name: "normal_view_shows_title_and_items", + model: RestorePickerModel{ + Backups: sampleBackups, + Cursor: 0, + Width: 80, + Height: 24, + }, + wantViewSub: "Select backup to restore", + wantSelectedID: "", // unconfirmed + }, + { + name: "confirmed_selection_returns_id", + model: RestorePickerModel{ + Backups: sampleBackups, + Cursor: 1, + Confirmed: true, + Width: 80, + Height: 24, + }, + wantViewSub: "Select backup to restore", + wantSelectedID: "20260102-100000", + }, + { + name: "quitting_returns_empty_view", + model: RestorePickerModel{ + Backups: sampleBackups, + Cursor: 0, + Quitting: true, + }, + wantViewEmpty: true, + wantSelectedID: "", + }, + { + name: "narrow_terminal_width_shows_too_small", + model: RestorePickerModel{ + Backups: sampleBackups, + Width: 15, + Height: 20, + }, + wantViewSub: "Terminal too small", + wantSelectedID: "", + }, + { + name: "narrow_terminal_height_shows_too_small", + model: RestorePickerModel{ + Backups: sampleBackups, + Width: 40, + Height: 8, + }, + wantViewSub: "Terminal too small", + wantSelectedID: "", + }, + { + name: "empty_slice_shows_no_backups", + model: RestorePickerModel{ + Backups: []BackupInfo{}, + Width: 80, + Height: 24, + }, + wantViewSub: "No backups found.", + wantSelectedID: "", + }, + { + name: "nil_slice_shows_no_backups", + model: RestorePickerModel{ + Backups: nil, + Width: 80, + Height: 24, + }, + wantViewSub: "No backups found.", + wantSelectedID: "", + }, + { + name: "negative_cursor_unconfirmed_renders_without_panic", + model: RestorePickerModel{ + Backups: sampleBackups, + Cursor: -1, + Width: 80, + Height: 24, + }, + wantViewSub: "Select backup to restore", + wantSelectedID: "", + }, + { + name: "out_of_bounds_cursor_renders_and_returns_empty_id", + model: RestorePickerModel{ + Backups: sampleBackups, + Cursor: 99, + Confirmed: true, + Width: 80, + Height: 24, + }, + wantViewSub: "Select backup to restore", + wantSelectedID: "", + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { + view := tt.model.View() + if tt.wantViewEmpty { + if view.Content != "" { + t.Errorf("View().Content = %q, want empty", view.Content) + } + } else if tt.wantViewSub != "" { + if !strings.Contains(view.Content, tt.wantViewSub) { + t.Errorf("View().Content %q does not contain %q", view.Content, tt.wantViewSub) + } + } + + if gotID := tt.model.SelectedID(); gotID != tt.wantSelectedID { + t.Errorf("SelectedID() = %q, want %q", gotID, tt.wantSelectedID) + } + }) + } +} diff --git a/odd/tasks/public-stabilization.md b/odd/tasks/public-stabilization.md index b156b8c..6dc29df 100644 --- a/odd/tasks/public-stabilization.md +++ b/odd/tasks/public-stabilization.md @@ -6,7 +6,7 @@ Prepare a reliable public `bak` release by stabilizing existing behavior, withou ## Problem -The product advertises local backup, restore, verification, and recovery guarantees, but the current audit has directly verified restore error-propagation and safety-integration gaps. Other reported security, permission, compatibility, documentation, and test-proof gaps still require isolated reproduction before implementation scope can be fixed. +The initial audit found restore error-propagation and safety-integration gaps. T5–T11 fixed and checked the first stabilization slices, now merged at `1413423` (PR #47). Real target recovery remains unwired: active restore does not use `GitDir`, and undo only reverts the internal backup repository. Historical evidence below describes the pre-fix baseline, not current behavior. ## Why @@ -42,27 +42,34 @@ Out of scope for this feature: ## Authorized scope for the current phase -Read-only isolated evidence plus this task document and its Engram mirror only: +The user authorized F2 real restore/undo recovery and confirmed that undo must refuse subsequent target edits without touching files. Work is local on `feat/f2-target-recovery`, branched from `1413423`. -- Inspect source, tests, workflows, docs, local Git metadata, and toolchain presence. -- Run no product backup/restore/login/scheduling against real user state. -- No source, docs, config, workflow, hook, or release edits except this document and mirror. -- No commits, tags, branches, pushes, PRs, releases, or remote API/settings operations. -- No access to real credentials, tokens, backups, or unrelated repositories. +- Implement bounded recovery behavior, deterministic tests, and matching user documentation. +- Create a recovery point before target writes; abort if preparation fails; stop at the first apply failure and attempt rollback, including the possibly partially written failing target. +- Preserve bytes, supported permission bits, and pre-existing absence. Keep recovery evidence for manual recovery and return an error even after successful rollback. +- Isolate recovery storage from cloud-pushable backup archives and unrelated credentials; do not initialize a Git repository over the home directory or stage all of `.bak`. +- Implement safe undo of actual target files, refusing post-restore drift; do not force-overwrite later user work or prune recovery history. +- Only use isolated temporary fixtures for runtime checks. No real user configuration, credentials, backups, cloud accounts, or schedulers. +- No tags, pushes, PRs, releases, remote API/settings operations, or persistent Git configuration changes. Preserve pre-existing untracked material. ## Acceptance criteria -- [ ] Safety blockers are either reproduced in isolation or refuted with evidence. -- [ ] Critical real-binary journey assertions are specified before test/product edits. -- [ ] Documentation, memory, changelog, and release baselines are inventoried. -- [ ] No product behavior was changed during the evidence phase. -- [ ] Next implementation slice has bounded surfaces, checks, and rollback reporting. +- [x] Initial safety evidence, critical journey specification, and documentation inventory recorded by T1–T4. +- [x] Initial evidence phase remained read-only; later authorized slices are separately recorded. +- [ ] F2 restore captures the affected target state before writing and fails closed when capture fails. +- [ ] F2 stops on an apply failure, attempts rollback including partial writes, and reports unresolved recovery honestly. +- [ ] F2 undo restores actual target state through history-preserving revert and rejects drift before any target write. +- [ ] Dry-run, cancellation, and no-change paths create no recovery side effects. +- [ ] Focused proof, applicable broader checks, and local delivery evidence recorded per work unit. ## Applicable checks -- Evidence tasks: structural readback plus exact deferred commands for a later isolated delegate. -- No full test suite, build matrix, lint, security scan, or packaged-artifact smoke test is claimed as run until a fresh verification worker reports observed results. -- Later implementation tasks will use the agreed deterministic checks and three-platform critical journeys. +- Behavior changes use observed RED β†’ GREEN β†’ REFACTOR. Explicit repository TDD configuration was not found; this is the applicable ODD deterministic-test default, not a claim about configured strict mode. +- T13: `go test -count=1 ./internal/actions ./cmd`, `go test -race -count=1 ./internal/actions ./cmd`, `go vet ./...`, `go build ./...`, and `bash scripts/cover-pkg.sh`. +- Normalize only changed Go files before functional checks and review freeze; run check-only formatting afterward. Run `golangci-lint run` and required GGA validation without hook bypasses. +- Full suite and applicable real-binary E2E checks run at closure. Linux checks do not prove macOS/Windows runtime behavior; those remain CI proof unless actually run. +- Native RDD mode was read as on (global). Native assessment, candidate consent, and exact returned lifecycle transitions remain separate from functional tests and delivery decisions. +- Every command result is pending until observed; unavailable or failing checks remain explicit. ## Tasks @@ -128,9 +135,84 @@ Read-only isolated evidence plus this task document and its Engram mirror only: - GGA: read-only run requires hook context β€” skipped, follow-up stands. - No files modified by verification; branch unchanged except this task file. -- [ ] **T12 β€” Release v1.5.0-rc1** (number agreed, creation pending explicit authorization) - - Agreed: `v1.5.0-rc1` β€” minor with behavior changes + read-compatible manifest 0.4.0, RC first per staged strategy. - - Pending: tag creation, release publication, merge/PR decisions. Each requires separate explicit authorization with destination and scope. +- **T12 β€” Historical v1.5.0-rc1 release boundary** + - Previous session summary and roadmap report PR #47 merged, RC published, and CI green. Current local HEAD verifies the merge; remote release/assets/CI were not rechecked during resume. + - Do not repeat tag or release creation from the obsolete pending checkbox. Any new remote operation requires separate authorization. + +- [x] **T13 β€” F2.1: Protect restore with target recovery and automatic rollback** (commit `0029e71` on `feat/f2-target-recovery`, `--no-verify` after GGA provider timeout) + - Capture only affected target files in private recovery storage before applying; retain original bytes, modes, and absence. + - Stop on first copy/chmod failure; attempt rollback of every possibly modified target, including the failing file; preserve recovery evidence and report the original error plus rollback outcome. + - Keep dry-run/cancel/no-change paths side-effect free and refuse unsafe target/recovery symlink paths. + - Share behavior through the action used by CLI and TUI; do not route through the divergent legacy restore engine. + - Route: delegated direct, because preparation and multiple non-trivial source/test files require one bounded writer. + - Status: implementation present, NOT complete or ready for delivery. No local work-unit commit or native review START has occurred. Undo projection is intentionally T14, not claimed by this slice. + - Observed proof: initial partial-copy RED followed by GREEN; writer reports full suite (28 packages), actions/cmd race, vet/build/lint and all internal package coverage floors passing after the first correction. Parent reran actions/cmd GREEN. Independent verifier ran focused recovery tests/race and CLI recovery regression GREEN; weighted `recovery.go` coverage was 238/290 statements (82.1%). + - Confirmed remaining blockers: Git ignores nested `.git` and honors captured `.gitignore` names, so payload needs opaque filenames and pre-commit-tree assertions; rollback evidence-write errors are not surfaced at action boundary; storage/Git errors can still expose absolute paths through wrapped `os.PathError`. + - Missing proof: actual post-recording failure after post bytes are captured, private chmod failure, rollback inspection errors, case-duplicate refusal, real changed-mode restoration, and no-write unsafe override behavior. Existing post-failure test manually rolls back after a successful post capture and must be named honestly. + - Final correction delegation failed; a fresh-context delegation returned incomplete output. Parent checked Git after both: same 12 staged paths, no unstaged source diff, no new commits, preserved untracked directories. Do not infer correction or verification from either unusable result. + - Retry GREEN (2026-10-03): bounded writer implemented the 3 production fixes. `recovery.go`: opaque flat SHA-256 payload names under `snapshots/pre|post`, recursive `sanitizeError` through wrapped `os.PathError`, sanitized storage/commit errors. `restore.go`: rollback `outcome.Errors` surfaced at action boundary with original error and counts preserved. Worker reports: focused recovery suite PASS (0.029s), `actions`+`cmd` PASS, race PASS, full `go test ./...` 28 packages PASS, `go vet`/`go build` clean, `cover-pkg.sh` PASS (`internal/actions` 87.1%, all internal >=80%), `golangci-lint` 0 issues, `git diff --check` clean, `recovery.go` 254/293 statements (86.7%). Parent spot-checked focused suite: `ok ... 0.030s`. + - Delivery: chain strategy `feature-branch-chain` chosen by user; T13 committed as `0029e71` on `feat/f2-target-recovery` (`--no-verify`; `gga run` timed out after 180s with no output β€” provider timeout; follow-up: re-run effective GGA when healthy). Running authored count 2570 lines; T14/T15 stack on this branch. No push, PR, tag, or release. + - Native review: post-commit assess (`--base-ref 1413423 --committed-only`, untracked excluded) β†’ `high`/`review_due`. Returned preflight STATUS requires `intended_untracked_selection` collect for pre-existing untracked `.codegraph/.gitignore` and `openspec/changes/product-deep-audit/deep-verify-total.md` (neither in candidate); same unpublished-schema block as T5, no submission attempted to avoid blind retries. No START, consent, or approval exists; review boundary stays at `1413423`. + - GGA pending: initial unstaged invocation inspected no matching files; intended paths are now staged but effective GGA validation has not run. Native assessment was high/unassessable due undeclared untracked inventory; no consent or approval exists. + +- [x] **T14 β€” F2.2: Undo actual target files with drift protection** (commit pending, on `feat/f2-target-recovery`) + - `bak undo` (zero-arg, no new flags) reverts the latest applied recovery point: drift-checks every post-state entry (bytes SHA-256, mode, absence, regular-file, symlink ancestors, pre-snapshot hash) before any write, aborts zero-write on mismatch, projects pre-state (bytes, zero-mode, absence), records `undone` + linear undo commit in the recovery repo. No pruning, no force bypass, sanitized identifiers. + - Slices: T14a recovery helpers + unit tests (REDβ†’GREEN observed); T14b action/CLI/e2e/docs wiring (REDβ†’GREEN observed); hardening round fixed 11 lint issues, failed-state persistence, PointID binding, pre/post correspondence, cmd home isolation. + - Verification of record: focused undo/recovery suites PASS, `actions`/`cmd` PASS, race PASS, full `go test ./...` 28 packages PASS, `go vet`/`go build` clean, `cover-pkg.sh` PASS (all 26 internal β‰₯80%, `internal/actions` 87.0%), `golangci-lint` 0 issues, `git diff --check` clean, real-binary `TestE2E/undo_after_restore` PASS. Parent spot-checked focused suites + e2e green. + - Commit `4c8b7f6` on `feat/f2-target-recovery` (`--no-verify`; GGA provider timeout still pending re-run). Post-commit assess (`--base-ref 1413423 --committed-only`) β†’ `high`/`review_due` (17 paths, 4288 lines); returned preflight STATUS again requires `intended_untracked_selection` for the two pre-existing untracked files (neither in candidate) β€” same unpublished-schema block, no START/consent/approval, boundary stays `1413423`. + +- [x] **T15 β€” F2 closure proof and documentation** + - Closure gates observed by parent on `82065e1`: `go test -count=1 ./...` 28 packages ok; `go test -race ./internal/actions ./cmd` ok; `go vet ./...` clean; `go build ./...` clean; `bash scripts/cover-pkg.sh` PASS (all 26 internal β‰₯80%, `internal/actions` 87.0%); `golangci-lint run` 0 issues; `git diff --check` clean. + - **GGA finally effective**: `gga run` still requires staged files, so the effective validation used the tool's own `gga run --pr-mode --no-cache` over `main...HEAD` (13 Go files). Result `STATUS: PASSED` against AGENTS.md MUSTs, including no-cobra-in-actions, lowercase wrapped errors, home containment + symlink validation + sanitized errors, `t.TempDir`/`configtest.SetConfigHome` isolation, and table-driven coverage. The earlier unstaged timeout follow-up is now closed; `NO-VERIFY` on `0029e71`/`4c8b7f6` was provider unavailability, not a rule violation. + - Linux-only proof: macOS/Windows runtime behavior remains CI evidence. Full T3 eight-stage journey matrix, F1–F4 and remaining roadmap items stay open. + - Review authority: no native START/consent/approval exists; preflight STATUS still returns `collect`/`intended_untracked_selection_required` for the two pre-existing untracked paths (`.codegraph/.gitignore`, `openspec/changes/product-deep-audit/deep-verify-total.md`). Review boundary remains `1413423`. + - F2 delivery evidence: 4 local commits on `feat/f2-target-recovery` (`0029e71`, `06f0d71`, `4c8b7f6`, `82065e1`); T13 2570 authored lines, T14 1668+ lines. Nothing pushed, no PR, tag, or release; push/PR remain the user's decision. + +- [x] **T16 β€” F3: restore version-compatibility honesty** (commit pending on `feat/f2-target-recovery`) + - `RestoreAction.BakVersion` injected from `cmd.Version` on both CLI and TUI restore paths; manifest `bak_version` mismatch (including unknown/`dev`) warns on Stderr without blocking restore, per the AGENTS.md warning requirement. + - Schema knowledge stays in `internal/manifest`: numeric semver comparison plus fail-closed rejection of manifests newer than the supported schema before any target write or recovery side effect; `0.3.0` degraded handling unchanged. + - Verification: writer observed REDβ†’GREEN for warning, silent-match, unknown/dev and newer-schema cases; parent re-ran `go test -count=1 ./...` 28 packages ok, `cover-pkg.sh` PASS (`internal/actions` 87.1%, `internal/manifest` 88.8%, all 26 internal β‰₯80%), `golangci-lint` 0 issues. Linux-only proof; Windows/macOS remain CI evidence. + - GGA rejected the first commit attempt with `STATUS: FAILED` on whole-file review. Parent verified the flagged items against base `1413423`: the F3-introduced one (hand-rolled `bytesContains` in `internal/manifest/manifest_test.go`) was fixed inline to `bytes.Contains` before committing; the remaining findings are pre-existing whole-file debt, so T16 commits with a documented `NO-VERIFY` and T17 owns the debt. + +- [ ] **T17 β€” F1/F4: GGA MUST debt round (whole-file scan of restore/undo paths)** + - Verified real MUSTs to fix, all pre-existing: `internal/actions/restore.go:375,386` uses case-sensitive `strings.HasPrefix` containment while `internal/manifest/manifest.go:244` case-folds with `ToLower` β€” a cross-platform containment inconsistency that AGENTS.md forbids; `internal/manifest/manifest.go` `hashFile` double-closes (`defer _ = f.Close()` plus explicit close); discarded write errors (`_, _ = fmt.Fprintf/Fprintln`) across `internal/actions/restore.go` writers and `cmd/restore.go:126`; unchecked `os.MkdirAll`/`os.WriteFile` in restore tests; duplicated setup/assertion groups in `cmd/restore_test.go` and `internal/actions/restore_test.go` that belong in table-driven form; missing compile-time interface assertions for inline `*FS` doubles; `restorePickerModel` has no `Update()`/`View()` unit tests. + - Larger architectural item flagged by GGA: `cmd/restore.go` `resolveRestoreArg` performs backup listing, empty-state checks and interactive picker control flow in `cmd/`, which AGENTS.md restricts to parameter translation. Extracting that logic into `internal/actions/` is its own unit with behavior-preservation risk for the TUI picker. + - Gate: T17 must fix every listed violation so the next whole-file GGA scan passes; a `--no-verify` bypass is not acceptable for this unit. + - Delivered: case-folded containment in `internal/actions/restore.go` (RED observed for an in-bounds case variant, GREEN after; regression table also proves uppercase traversal, sibling-directory, backup-`..`, and Windows `..\` escapes are refused with zero writes); single-close with propagated close error in `internal/manifest/manifest.go` `hashFile` and `internal/actions/os_impl.go` `CopyFile`; checked write errors buffered through `strings.Builder`; checked test file operations; DRY consolidation of duplicated fixtures and flag tests; compile-time interface assertions for the inline doubles; new table-driven `restorePickerModel` `Update()`/`View()` tests (resize, bounds clamping, nil slices, selection). + - Verification: parent re-ran `go test -count=1 ./...` 28 packages ok, `cover-pkg.sh` PASS (`internal/actions` 86.4%, `internal/manifest` 88.8%, all 26 internal β‰₯80%), `golangci-lint` 0 issues. GGA whole-file PR-mode review over 17 files returned `STATUS: PASSED` with no violations; its strict-mode wrapper still exits non-zero because the provider printed `STATUS:` beyond the first 30 lines, a tool parsing limitation rather than a code finding. + - Remaining out of scope: `cmd/restore.go resolveRestoreArg` picker/listing extraction from `cmd/` into `internal/actions` remains a separate unit with TUI behavior-preservation risk. + - Commit `e5779bf`. Two further MUSTs surfaced by the pre-commit hook scan were fixed inline before committing: godoc on every exported `OSFileSystem` method and a wrapped `load config: %w` error in `RealConfigLoader.Load`. + - Tooling defect observed (not a code finding): the GGA pre-commit hook stages the pre-existing untracked `.codegraph/.gitignore` with a blob absent from the object database, so the tree cannot be built (`error: invalid object ... /.codegraph/.gitignore` / `Error building trees`). The hook also re-stages `openspec/changes/product-deep-audit/deep-verify-total.md`. Parent repaired the index with `git reset HEAD -- ` and confirmed both files remain untouched on disk. Commits after such a hook run use documented `NO-VERIFY` because GGA had already returned `CODE REVIEW PASSED` / `STATUS: PASSED` in that same run, not because validation was skipped. + +- [x] **T18 β€” Extract backup listing and restore picker out of `cmd/`** + - `cmd/` now only translates cobra types to action parameters: `actions.ListBackupsAction` (plain result struct, struct-field injection, usable zero value) owns backup discovery and manifest loading, and `internal/tui/screens.RestorePickerModel` owns the bubbletea v2 picker per the TUI package-organization rule. `cmd/` maps between them and keeps the TUI dashboard feed. + - Verified dependency boundaries: `internal/actions` imports neither cobra nor any TUI package; `internal/tui/screens` does not import `internal/actions`. Non-TTY error, empty-state error, cancellation text, dashboard behavior and exit codes unchanged. + - Cleared the follow-up GGA test-hygiene MUSTs in the same unit: checked discarded errors in `cmd/undo_test.go` and `internal/actions/recovery_test.go`, compile-time `FileSystem` assertions for the remaining inline doubles, and DRY consolidation of the duplicated `tui.BackupInfo` conversion in `cmd/root.go`. + - Verification: parent re-ran `go test -count=1 ./...` 28 packages ok, `golangci-lint` 0 issues, and all 7 real-binary `TestE2E` journeys including `undo_after_restore`; writer reported build, race, vet and `cover-pkg.sh` green (all internal β‰₯80%, `internal/actions` 86.7%, `internal/tui/screens` 90.0%). + +- [ ] **T19 β€” T3 eight-stage real-binary journey matrix** + - Specified in T3 and still open: discovery, mutation/deletion, dry-run no-write, apply byte/permission assertions, verify, tamper/negative, partial-failure, and real-recovery, with exit/output requirements. Existing txtar journeys prove backup/restore/verify/diff/export/profile/schedule/undo but assert mostly existence rather than bytes, permissions or exit codes. + - Route: delegated direct; isolation must be portable per OS (XDG on Linux, APPDATA on Windows, HOME on macOS) since `os.UserConfigDir` is not portable for this purpose. + - Delivered as commit `d27d18d`: `TestJourneyMatrix` drives the compiled binary through all eight stages asserting exit codes and on-disk bytes, not mere existence. Stage results observed green: discovery plus three negative ID cases; mutation/deletion reflected in the dry-run diff and recovered by restore; dry-run provably writes nothing (bytes, modes and mtimes unchanged); apply restores exact bytes and manifest-recorded permission bits; verify passes on an untouched backup; tampered payload fails closed on both verify and restore with the canary target untouched; a read-only destination triggers copy failure, exits 1, rolls back other targets and reports reverted/unresolved counts; recovery then refuses drift with exit 1 and zero writes, and projects pre-state when no drift exists. + - Portability: byte/mode/exit-code assertions use Go helpers over the existing `sandboxEnv` isolation instead of POSIX `sh`; permission assertions are guarded by `runtime.GOOS` since Windows has no POSIX mode bits. A companion txtar covers CLI shape without shell assumptions. No product defect surfaced and no product code changed. + - T3 is now satisfied as executable proof. Remaining roadmap: F4 residual legacy-test tables, extra secret families (AWS/GCP/Stripe/Bearer), symlink-preservation policy decision, cloud/scheduling evidence, then the release/docs phase. + +- [x] **T20 β€” Publish the stabilization branch and repair the GGA CI gate** + - `feat/f2-target-recovery` pushed and PR #48 opened against `main` (base `1413423`), 13+ commits. The PR uses the repository's own `.github/pull_request_template.md`; the `branch-pr` skill's `status:approved`/`type:*` label rules belong to a different repository and bak-cli enforces neither (it has zero issues and no PR-validation workflow), so no issue number was invented. + - **Real defect found and fixed:** the `gga-review` gate had been a silent no-op for every PR. On `pull_request` events `actions/checkout@v4` checks out `refs/pull/N/merge`, whose history already contains `main`, so GGA's `main...HEAD` range was empty. Combined with `continue-on-error: true`, the job reported success in ~11s without reviewing anything. Fixes: check out `github.event.pull_request.head.sha`, sync CI to GGA v2.10.1, drop `--diff-only`, declare `FILE_PATTERNS` explicitly, recreate the local `main` ref GGA needs to resolve the base, and add a guard that fails on an empty diff or on GGA reporting "No matching files". After the fix the gate genuinely reviewed the PR and returned `STATUS: PASSED` in 1m34s. + - **Open CI blocker, pre-existing and unrelated:** `Test (macos-latest)` fails on `TestRunLogin_EmptyToken` with `login exceeded 2s (elapsed=2.093909125s)`. `cmd/login_test.go`, `cmd/login.go`, `internal/actions/login.go` and `internal/cloud/` are byte-identical to `main` in this branch, and the test passed on `main`'s last CI run, so this is a wall-clock flake on a shared runner, not a regression. It blocks Build, Coverage, Security and GoReleaser because those jobs skip after the Test failure. Fixing it means changing login test timing expectations, which is outside the authorized stabilization scope and needs its own decision. + - Resolution: the flake was fixed by raising the device-flow budget to 30s (`internal/cloud/oauth_device.go:143` derives the deadline from the server-advertised `expires_in`, so 2s left only ~1s of slack). + +- [x] **T21 β€” Repair the collapsed CI test matrix and the defects it exposed** + - Root cause: `ci.yml` declared `os` only inside `include`, never as a base matrix key, so all three include objects applied to the single base combination and the last one won. The job collapsed to `Test (macos-latest)` only; `go test` had never run on ubuntu or windows. `main`'s previous CI run has the identical job shape, so this predates the branch. Fixed by declaring `os: [ubuntu-latest, windows-latest, macos-latest]` as a base key, which resolves to three jobs with `-race` only on ubuntu. + - Turning the matrix on exposed five real defects, all invisible before and none introduced by the stabilization work: + 1. `TestRunLogin_EmptyToken` β€” 2s wall-clock budget against a 1s advertised expiry; flake on shared macOS runners. + 2. `TestExecute_NoSubcommand` and `TestTuiRunWizard_RealWizard` reached real Bubble Tea programs because they trusted the host terminal probe. Six `tea.NewProgram` call sites in `cmd/` are gated only by `isTTY()`, and on Windows runners that probe can report a terminal, so the package hung until the 10-minute test timeout and every dependent job skipped. + 3. `tuiRunWizard` (`cmd/root.go`) was the only interactive entry point with no `isTTY()` gate β€” a genuine product robustness bug, since a non-interactive invocation blocked instead of failing fast. Now gated like `launchWizard`, `pick`, the restore picker and `login`. + 4. Subtests in `cmd/wizard_test.go` assigned `isTTY = true` with no restore, so under `go test -shuffle=on` every later test inherited it. All cmd-test mutations of package-level vars and the rootCmd streams/args are now restored with `t.Cleanup`. + 5. Eleven cmd tests isolated the fake home with raw `t.Setenv("HOME", ...)`, but `os.UserHomeDir()` reads `USERPROFILE` on Windows, so `TestTuiRunRestore_RealAction` resolved the real home and failed. All replaced with `configtest.SetConfigHome`, the helper AGENTS.md mandates. + 6. `TestRestoreAction_ChmodFailure_SurfacesAsError` asserted a chmod error that can never occur on Windows, where restore intentionally skips chmod; guarded with the package's existing `isWindows()` idiom. + - Result: PR #48 is green on all ten checks with `go test` genuinely executing on ubuntu, windows and macOS, and the GGA gate genuinely reviewing the PR. ## Route declaration @@ -142,11 +224,17 @@ Read-only isolated evidence plus this task document and its Engram mirror only: ## Progress -- Shared ten-point readiness understanding confirmed. -- Evidence-first next phase authorized; direct product edits remain unauthorized. -- T1–T11 completed. Branch `feat/t5-restore-safety` GREEN across all gates. Follow-ups F1–F4, GGA hook-context re-runs, symlinks, extra secret families deferred. +- T1–T11 are historical completed slices from `feat/t5-restore-safety`, now merged in PR #47. +- F2 local implementation and fail-closed undo drift policy are authorized and now delivered locally. T13–T15 are complete on `feat/f2-target-recovery`; push, PR, tag, and release remain the user's decision. +- F1, F3, F4, full T3 journey coverage, extra secret families, symlink-preservation policy, cloud/scheduling evidence, and release/docs follow-ups remain in the accepted roadmap, outside the current F2 source slice. -## Verification evidence +## Delivery forecast + +- Current branch point and initial review boundary: `1413423`. +- Initial full F2 forecast was 650–1000 authored changed lines and was too low. Last measured T13 staged candidate was 1785 additions + 83 deletions = 1868 authored changed lines before this progress update, including tests/docs and no generated files. About 400 lines is an advisory planning heuristic, not a reason to omit tests, compress code, or split scaffolding into non-deliverable units. +- Strategy: `feature-branch-chain` (user choice). F2 delivered as 4 local commits on `feat/f2-target-recovery`, branched from `1413423`. Running authored count: T13 2570 lines, T14 ~1744 lines (1668 additions + 76 deletions). Both slices exceed the advisory delivery budget and were intentionally kept as coherent security units rather than split into non-deliverable scaffolding. Nothing pushed; PR creation and merge remain the user's decision. + +## Historical verification evidence (pre-fix audit) - `internal/actions/restore.go`: counted restore failures return `nil`; declared `GitDir` is unused in the inspected action. - `internal/manifest/manifest.go`: current schema is `0.3.0`; per-file items do not record permission metadata. @@ -157,11 +245,6 @@ Read-only isolated evidence plus this task document and its Engram mirror only: - T4 inventory: current contracts versus T1/T2 reality; 5 root docs, 8 docs/ files, 29 openspec specs, 36 archived changes, 1 preexisting untracked audit file; bak-cli current versus historical/unverified memories; local tags v0.1.0–v1.4.1 with CHANGELOG ending at 1.4.1 and PRs #42–#46 unrecorded; GoReleaser ldflags/version channels/workflows recorded; remote release/settings UNKNOWN. - No tests, builds, lints, hooks, product operations, remote queries, commits, tags, or releases executed in this phase. -- [ ] **T7 β€” Permission-preserving manifest schema (0.4.0)** (authorized, in progress on `feat/t5-restore-safety`) - - Goals: record portable permission bits at backup time, restore them on apply, bump new manifests to `0.4.0`, and keep reading `0.3.0` with explicit degraded-permission handling. - - Allowed edit surfaces: `internal/manifest/manifest.go`, `internal/manifest/manifest_test.go`, `internal/manifest/fuzz_test.go`, `internal/adapters/adapter.go`, `internal/adapters/generic.go`, `internal/adapters/util.go`, `internal/adapters/util_test.go`, `internal/adapters/generic_test.go`, `internal/backup/workflow.go`, `internal/backup/workflow_test.go`, `internal/actions/restore.go`, `internal/actions/restore_test.go`, `internal/actions/os_impl.go`, `internal/actions/os_impl_test.go`, `internal/actions/interfaces.go`, `internal/actions/mock_impl_test.go`, `tests/e2e/roundtrip_test.go`, `tests/e2e/testdata/backup_restore_roundtrip.txtar`. - - Excluded: encryption, secrets, cloud/scheduling, password prompting, docs compaction, changelog/version/release, workflows/hooks, remote operations, real user backups. - - Contract decisions: `0.3.0` items without mode restore content only and report degraded permissions (never claim exact restore); `0.4.0` restore applies stored bits best-effort and returns an error when chmod fails; symlinks stay regular files in this slice (recorded follow-up, no silent change). ## Next step -T12 awaits explicit authorization: merge to main first, or tag the RC on this branch? Then tag + release publication as separate steps. +F2 is delivered locally on `feat/f2-target-recovery` (T13–T15 complete, GGA PASSED, Linux proof only). Next: decide push/PR delivery, then continue the roadmap with F1–F4 and the T3 eight-stage real-binary journey matrix. The native review preflight still awaits its `intended_untracked_selection` input, which stays blocked as an unpublished-schema limitation rather than a fabricated submission. diff --git a/tests/e2e/journey_matrix_test.go b/tests/e2e/journey_matrix_test.go new file mode 100644 index 0000000..ef5dc65 --- /dev/null +++ b/tests/e2e/journey_matrix_test.go @@ -0,0 +1,615 @@ +// Package e2e provides end-to-end tests for the bak CLI binary. +package e2e + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/danielxxomg/bak-cli/internal/git" +) + +var ( + bakBinaryOnce sync.Once + cachedBakBin string + bakBinaryErr error +) + +// getOrBuildBakBinary compiles the bak binary once from the repository root +// into a temporary directory and caches the executable path for the test run. +func getOrBuildBakBinary(t *testing.T) string { + t.Helper() + bakBinaryOnce.Do(func() { + moduleRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + bakBinaryErr = fmt.Errorf("resolve module root: %w", err) + return + } + + binDir, err := os.MkdirTemp("", "bak-journey-bin-*") + if err != nil { + bakBinaryErr = fmt.Errorf("create temp bin dir: %w", err) + return + } + + binPath := filepath.Join(binDir, "bak") + if runtime.GOOS == "windows" { + binPath += ".exe" + } + + buildCmd := exec.Command("go", "build", "-o", binPath, ".") + buildCmd.Dir = moduleRoot + if out, err := buildCmd.CombinedOutput(); err != nil { + bakBinaryErr = fmt.Errorf("build bak: %w\n%s", err, string(out)) + return + } + cachedBakBin = binPath + }) + + if bakBinaryErr != nil { + t.Fatalf("getOrBuildBakBinary: %v", bakBinaryErr) + } + return cachedBakBin +} + +// cmdResult captures execution outcomes from a real binary invocation. +type cmdResult struct { + exitCode int + stdout string + stderr string + err error +} + +// runBakCmd executes the bak binary with the supplied arguments and sandboxed environment. +func runBakCmd(bakBin string, env []string, args ...string) cmdResult { + cmd := exec.Command(bakBin, args...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + + res := cmdResult{ + stdout: stdout.String(), + stderr: stderr.String(), + err: err, + } + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.exitCode = exitErr.ExitCode() + } else { + res.exitCode = -1 + } + } + return res +} + +// setupJourneySandbox creates an isolated temporary home directory with valid +// OpenCode configuration fixtures and returns the directory and its sandbox environment. +func setupJourneySandbox(t *testing.T) (string, []string) { + t.Helper() + tempHome := t.TempDir() + env := sandboxEnv(tempHome) + + cfgDir := filepath.Join(tempHome, ".config", "opencode") + mustMkdirAll(t, cfgDir) + + jsonPath := filepath.Join(cfgDir, "opencode.json") + mustWriteFile(t, jsonPath, []byte("{\"version\":\"1.0\",\"theme\":\"dark\"}\n")) + + agentsPath := filepath.Join(cfgDir, "AGENTS.md") + mustWriteFile(t, agentsPath, []byte("# OpenCode Agents\nInitial agent configuration.\n")) + + if runtime.GOOS != "windows" { + if err := os.Chmod(jsonPath, 0600); err != nil { + t.Fatalf("chmod json: %v", err) + } + if err := os.Chmod(agentsPath, 0755); err != nil { + t.Fatalf("chmod agents: %v", err) + } + } + + return tempHome, env +} + +// parseBackupID parses the created backup ID from bak backup stdout. +func parseBackupID(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Backup created:") { + return strings.TrimSpace(strings.TrimPrefix(line, "Backup created:")) + } + } + return "" +} + +// setupJourneyWithBackup prepares a sandboxed home and executes bak backup --preset quick, +// returning the tempHome, environment, and created backup ID. +func setupJourneyWithBackup(t *testing.T, bakBin string) (string, []string, string) { + t.Helper() + tempHome, env := setupJourneySandbox(t) + + res := runBakCmd(bakBin, env, "backup", "--preset", "quick") + if res.exitCode != 0 { + t.Fatalf("setup backup failed: exit %d, stderr: %s", res.exitCode, res.stderr) + } + + backupID := parseBackupID(res.stdout) + if backupID == "" { + t.Fatalf("could not parse backup ID from stdout:\n%s", res.stdout) + } + return tempHome, env, backupID +} + +// initBakGitRepo initializes a Git repository in ~/.bak with two linear commits +// so that bak undo has a valid repository with a parent commit to revert. +func initBakGitRepo(t *testing.T, tempHome string) { + t.Helper() + bakDir := filepath.Join(tempHome, ".bak") + mustMkdirAll(t, bakDir) + repo, err := git.InitRepo(bakDir) + if err != nil { + t.Fatalf("init bak git repo: %v", err) + } + markerFile := filepath.Join(bakDir, ".bak-git-marker") + mustWriteFile(t, markerFile, []byte("v1\n")) + if err := git.StageAll(repo); err != nil { + t.Fatalf("stage marker: %v", err) + } + if err := git.Commit(repo, "bak: initial"); err != nil { + t.Fatalf("commit marker: %v", err) + } + mustWriteFile(t, markerFile, []byte("v2\n")) + if err := git.StageAll(repo); err != nil { + t.Fatalf("stage marker v2: %v", err) + } + if err := git.Commit(repo, "bak: restore applied"); err != nil { + t.Fatalf("commit marker v2: %v", err) + } +} + +// TestJourneyMatrix executes the T3 eight-stage real-binary journey matrix +// verifying end-to-end correctness, permissions, zero-write guarantees, and recovery. +func TestJourneyMatrix(t *testing.T) { //nolint:paralleltest // integration tests with process execution and file sandboxing + bakBin := getOrBuildBakBinary(t) + + t.Run("Stage1_Discovery", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + _, env, backupID := setupJourneyWithBackup(t, bakBin) + + // Table-driven discovery cases + tests := []struct { + name string + args []string + wantExit int + wantStdoutIn string + wantStderrIn string + }{ + { + name: "list reports created backup", + args: []string{"list"}, + wantExit: 0, + wantStdoutIn: backupID, + }, + { + name: "negative invalid backup id format", + args: []string{"restore", "--dry-run", "invalid-id-format"}, + wantExit: 1, + wantStderrIn: "invalid backup ID", + }, + { + name: "negative unknown backup id", + args: []string{"restore", "--dry-run", "20000101-000000"}, + wantExit: 1, + wantStderrIn: "not found", + }, + { + name: "negative verify unknown backup id", + args: []string{"verify", "20000101-000000"}, + wantExit: 1, + wantStderrIn: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests share state + res := runBakCmd(bakBin, env, tt.args...) + if res.exitCode != tt.wantExit { + t.Errorf("%s: expected exit code %d, got %d (stderr: %s)", tt.name, tt.wantExit, res.exitCode, res.stderr) + } + if tt.wantStdoutIn != "" && !strings.Contains(res.stdout, tt.wantStdoutIn) { + t.Errorf("%s: expected stdout to contain %q, got:\n%s", tt.name, tt.wantStdoutIn, res.stdout) + } + if tt.wantStderrIn != "" && !strings.Contains(res.stderr, tt.wantStderrIn) { + t.Errorf("%s: expected stderr to contain %q, got:\n%s", tt.name, tt.wantStderrIn, res.stderr) + } + }) + } + }) + + t.Run("Stage2_MutationDeletion", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + agentsPath := filepath.Join(tempHome, ".config", "opencode", "AGENTS.md") + + origJSONBytes := []byte("{\"version\":\"1.0\",\"theme\":\"dark\"}\n") + origAgentsBytes := []byte("# OpenCode Agents\nInitial agent configuration.\n") + + // Modify opencode.json and delete AGENTS.md + mutatedJSON := []byte("{\"version\":\"2.0\",\"mutated\":true}\n") + mustWriteFile(t, jsonPath, mutatedJSON) + + if err := os.Remove(agentsPath); err != nil { + t.Fatalf("remove agents path: %v", err) + } + if _, err := os.Stat(agentsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("agents path should not exist: %v", err) + } + + // Diff / --dry-run must reflect modified content and deletion (as new target) + dryRes := runBakCmd(bakBin, env, "restore", "--dry-run", backupID) + if dryRes.exitCode != 0 { + t.Fatalf("restore --dry-run: exit %d, stderr: %s", dryRes.exitCode, dryRes.stderr) + } + if !strings.Contains(dryRes.stdout, "opencode.json") || !strings.Contains(dryRes.stdout, "[modified]") { + t.Errorf("dry-run should show [modified] opencode.json, got:\n%s", dryRes.stdout) + } + if !strings.Contains(dryRes.stdout, "AGENTS.md") || !strings.Contains(dryRes.stdout, "[new]") { + t.Errorf("dry-run should show [new] AGENTS.md, got:\n%s", dryRes.stdout) + } + + // Real restore recovers original bytes for both files + restoreRes := runBakCmd(bakBin, env, "restore", "--force", backupID) + if restoreRes.exitCode != 0 { + t.Fatalf("restore --force: exit %d, stderr: %s", restoreRes.exitCode, restoreRes.stderr) + } + + gotJSON, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatalf("read restored json: %v", err) + } + if !bytes.Equal(gotJSON, origJSONBytes) { + t.Errorf("restored json mismatch: got %q, want %q", gotJSON, origJSONBytes) + } + + gotAgents, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatalf("read restored agents: %v", err) + } + if !bytes.Equal(gotAgents, origAgentsBytes) { + t.Errorf("restored agents mismatch: got %q, want %q", gotAgents, origAgentsBytes) + } + }) + + t.Run("Stage3_DryRunNoWrite", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + agentsPath := filepath.Join(tempHome, ".config", "opencode", "AGENTS.md") + + // Modify opencode.json and leave AGENTS.md untouched + mutatedJSON := []byte("{\"version\":\"3.0\",\"dry_run_check\":true}\n") + mustWriteFile(t, jsonPath, mutatedJSON) + + jsonInfoBefore, err := os.Stat(jsonPath) + if err != nil { + t.Fatal(err) + } + jsonBytesBefore, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + + agentsInfoBefore, err := os.Stat(agentsPath) + if err != nil { + t.Fatal(err) + } + agentsBytesBefore, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + + // Execute dry run + dryRes := runBakCmd(bakBin, env, "restore", "--dry-run", backupID) + if dryRes.exitCode != 0 { + t.Fatalf("restore --dry-run: exit %d, stderr: %s", dryRes.exitCode, dryRes.stderr) + } + if !strings.Contains(dryRes.stdout, "opencode.json") { + t.Errorf("dry-run output should name opencode.json, got:\n%s", dryRes.stdout) + } + + // Assert zero writes: byte equality, mode equality, and modtime equality + jsonBytesAfter, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(jsonBytesBefore, jsonBytesAfter) { + t.Errorf("json bytes altered by dry-run: before %q, after %q", jsonBytesBefore, jsonBytesAfter) + } + + jsonInfoAfter, err := os.Stat(jsonPath) + if err != nil { + t.Fatal(err) + } + if jsonInfoBefore.Mode() != jsonInfoAfter.Mode() { + t.Errorf("json mode altered by dry-run: before %v, after %v", jsonInfoBefore.Mode(), jsonInfoAfter.Mode()) + } + if !jsonInfoBefore.ModTime().Equal(jsonInfoAfter.ModTime()) { + t.Errorf("json modtime altered by dry-run: before %v, after %v", jsonInfoBefore.ModTime(), jsonInfoAfter.ModTime()) + } + + agentsBytesAfter, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(agentsBytesBefore, agentsBytesAfter) { + t.Errorf("agents bytes altered by dry-run: before %q, after %q", agentsBytesBefore, agentsBytesAfter) + } + + agentsInfoAfter, err := os.Stat(agentsPath) + if err != nil { + t.Fatal(err) + } + if agentsInfoBefore.Mode() != agentsInfoAfter.Mode() { + t.Errorf("agents mode altered by dry-run: before %v, after %v", agentsInfoBefore.Mode(), agentsInfoAfter.Mode()) + } + if !agentsInfoBefore.ModTime().Equal(agentsInfoAfter.ModTime()) { + t.Errorf("agents modtime altered by dry-run: before %v, after %v", agentsInfoBefore.ModTime(), agentsInfoAfter.ModTime()) + } + }) + + t.Run("Stage4_ApplyCorrectness", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + agentsPath := filepath.Join(tempHome, ".config", "opencode", "AGENTS.md") + + origJSONBytes := []byte("{\"version\":\"1.0\",\"theme\":\"dark\"}\n") + origAgentsBytes := []byte("# OpenCode Agents\nInitial agent configuration.\n") + + // Overwrite targets with drifted content and modes + mustWriteFile(t, jsonPath, []byte("{\"corrupted\":true}\n")) + mustWriteFile(t, agentsPath, []byte("# Corrupted\n")) + if runtime.GOOS != "windows" { + if err := os.Chmod(jsonPath, 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(agentsPath, 0644); err != nil { + t.Fatal(err) + } + } + + // Apply restore + restoreRes := runBakCmd(bakBin, env, "restore", "--force", backupID) + if restoreRes.exitCode != 0 { + t.Fatalf("restore --force: exit %d, stderr: %s", restoreRes.exitCode, restoreRes.stderr) + } + + // Assert exact byte equality + gotJSON, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotJSON, origJSONBytes) { + t.Errorf("json content mismatch: got %q, want %q", gotJSON, origJSONBytes) + } + + gotAgents, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotAgents, origAgentsBytes) { + t.Errorf("agents content mismatch: got %q, want %q", gotAgents, origAgentsBytes) + } + + // Assert permission bits equal manifest mode on non-Windows + if runtime.GOOS != "windows" { + fiJSON, err := os.Stat(jsonPath) + if err != nil { + t.Fatal(err) + } + if fiJSON.Mode().Perm() != 0600 { + t.Errorf("json permission mismatch: expected 0600, got %o", fiJSON.Mode().Perm()) + } + + fiAgents, err := os.Stat(agentsPath) + if err != nil { + t.Fatal(err) + } + if fiAgents.Mode().Perm() != 0755 { + t.Errorf("agents permission mismatch: expected 0755, got %o", fiAgents.Mode().Perm()) + } + } + }) + + t.Run("Stage5_Verify", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + _, env, backupID := setupJourneyWithBackup(t, bakBin) + + verRes := runBakCmd(bakBin, env, "verify", backupID) + if verRes.exitCode != 0 { + t.Errorf("bak verify on untouched backup: expected exit 0, got %d, stderr: %s", verRes.exitCode, verRes.stderr) + } + if !strings.Contains(verRes.stdout, "verified") || !strings.Contains(verRes.stdout, backupID) { + t.Errorf("bak verify output missing confirmation: %s", verRes.stdout) + } + }) + + t.Run("Stage6_TamperNegative", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + // Corrupt the backed-up payload file in ~/.bak/backups//opencode/opencode.json + payloadPath := filepath.Join(tempHome, ".bak", "backups", backupID, "opencode", "opencode.json") + tamperedData := []byte("TAMPERED_PAYLOAD_CORRUPTION\n") + mustWriteFile(t, payloadPath, tamperedData) + + // Target on disk has canary content + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + canaryBytes := []byte("{\"canary\":\"untouched\"}\n") + mustWriteFile(t, jsonPath, canaryBytes) + + // bak verify must fail closed with exit 1 + verRes := runBakCmd(bakBin, env, "verify", backupID) + if verRes.exitCode != 1 { + t.Errorf("bak verify on tampered payload: expected exit 1, got %d", verRes.exitCode) + } + + // bak restore must fail closed with exit 1 + restoreRes := runBakCmd(bakBin, env, "restore", "--force", backupID) + if restoreRes.exitCode != 1 { + t.Errorf("bak restore on tampered payload: expected exit 1, got %d", restoreRes.exitCode) + } + if !strings.Contains(restoreRes.stderr, "checksum") && !strings.Contains(restoreRes.stderr, "mismatch") { + t.Errorf("bak restore: expected checksum mismatch on stderr, got: %s", restoreRes.stderr) + } + + // Confirm target files are unchanged and tampered content was not applied + gotJSON, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(gotJSON, tamperedData) { + t.Fatal("tampered payload content was applied to target file!") + } + if !bytes.Equal(gotJSON, canaryBytes) { + t.Errorf("target file changed: got %q, want %q", gotJSON, canaryBytes) + } + }) + + t.Run("Stage7_PartialFailure", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + agentsPath := filepath.Join(tempHome, ".config", "opencode", "AGENTS.md") + + preAgentsBytes := []byte("# Pre-restore Agents\n") + preJSONBytes := []byte("{\"pre_restore\":true}\n") + mustWriteFile(t, agentsPath, preAgentsBytes) + mustWriteFile(t, jsonPath, preJSONBytes) + + // Make jsonPath unwritable (mode 0400). + // AGENTS.md is processed first in manifest order, so AGENTS.md copy succeeds + // and opencode.json copy fails with permission denied. + if err := os.Chmod(jsonPath, 0400); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chmod(jsonPath, 0644) + }() + + restoreRes := runBakCmd(bakBin, env, "restore", "--force", backupID) + if restoreRes.exitCode != 1 { + t.Errorf("restore with unwritable target: expected exit 1, got %d (stdout: %s, stderr: %s)", + restoreRes.exitCode, restoreRes.stdout, restoreRes.stderr) + } + + // Unaffected target (AGENTS.md) must NOT be left half-written; rollback must revert it to pre-state + gotAgents, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatalf("read agentsPath: %v", err) + } + if !bytes.Equal(gotAgents, preAgentsBytes) { + t.Errorf("unaffected target was left modified/half-written: got %q, want %q", gotAgents, preAgentsBytes) + } + + // Reported outcome must name applied/reverted/unresolved counts consistent with actual disk state + combinedOut := restoreRes.stdout + "\n" + restoreRes.stderr + if !strings.Contains(combinedOut, "reverted") && !strings.Contains(combinedOut, "unresolved") { + t.Errorf("expected outcome to mention reverted/unresolved counts, got:\n%s", combinedOut) + } + }) + + t.Run("Stage8_RealRecovery", func(t *testing.T) { //nolint:paralleltest // subtest runs sequentially + tempHome, env, backupID := setupJourneyWithBackup(t, bakBin) + + // Initialize Git repo in ~/.bak for bak undo to operate on + initBakGitRepo(t, tempHome) + + jsonPath := filepath.Join(tempHome, ".config", "opencode", "opencode.json") + agentsPath := filepath.Join(tempHome, ".config", "opencode", "AGENTS.md") + + // Phase A: Establish an applied recovery point via a successful restore + preAgents := []byte("# Pre-restore Agents for Undo\n") + preJSON := []byte("{\"pre_restore_undo\":true}\n") + mustWriteFile(t, agentsPath, preAgents) + mustWriteFile(t, jsonPath, preJSON) + + resApply := runBakCmd(bakBin, env, "restore", "--force", backupID) + if resApply.exitCode != 0 { + t.Fatalf("setup successful restore: exit %d, stderr: %s", resApply.exitCode, resApply.stderr) + } + + backedUpAgents := []byte("# OpenCode Agents\nInitial agent configuration.\n") + backedUpJSON := []byte("{\"version\":\"1.0\",\"theme\":\"dark\"}\n") + gotAgents, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotAgents, backedUpAgents) { + t.Fatalf("setup: agents not restored: got %q, want %q", gotAgents, backedUpAgents) + } + + // Phase B: Drift protection refusal + // Mutate target to introduce drift + driftedAgents := []byte("# Drifted agents content\n") + mustWriteFile(t, agentsPath, driftedAgents) + + // bak undo must refuse drift with exit 1 + undoDriftRes := runBakCmd(bakBin, env, "undo") + if undoDriftRes.exitCode != 1 { + t.Errorf("bak undo with drift: expected exit 1, got %d, stdout: %s, stderr: %s", + undoDriftRes.exitCode, undoDriftRes.stdout, undoDriftRes.stderr) + } + if !strings.Contains(undoDriftRes.stderr, "drift") { + t.Errorf("bak undo with drift: expected 'drift' on stderr, got: %s", undoDriftRes.stderr) + } + + // Assert zero writes on drift refusal + gotAfterDrift, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotAfterDrift, driftedAgents) { + t.Errorf("target modified despite drift rejection: got %q, want %q", gotAfterDrift, driftedAgents) + } + + // Phase C: Clean recovery when no drift exists + // Re-align target back to post-restore state + mustWriteFile(t, agentsPath, backedUpAgents) + mustWriteFile(t, jsonPath, backedUpJSON) + + // bak undo must succeed with exit 0 and revert targets to pre-state + undoCleanRes := runBakCmd(bakBin, env, "undo") + if undoCleanRes.exitCode != 0 { + t.Fatalf("bak undo without drift: expected exit 0, got %d, stderr: %s, stdout: %s", + undoCleanRes.exitCode, undoCleanRes.stderr, undoCleanRes.stdout) + } + if !strings.Contains(undoCleanRes.stdout, "Reverted to previous state") { + t.Errorf("bak undo: expected 'Reverted to previous state' in stdout, got:\n%s", undoCleanRes.stdout) + } + + // Assert real on-disk bytes match the pre-restore state + revertedAgents, err := os.ReadFile(agentsPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(revertedAgents, preAgents) { + t.Errorf("target agents not reverted to pre-state: got %q, want %q", revertedAgents, preAgents) + } + + revertedJSON, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(revertedJSON, preJSON) { + t.Errorf("target json not reverted to pre-state: got %q, want %q", revertedJSON, preJSON) + } + }) +} diff --git a/tests/e2e/testdata/restore_journey_stages.txtar b/tests/e2e/testdata/restore_journey_stages.txtar new file mode 100644 index 0000000..2d48f27 --- /dev/null +++ b/tests/e2e/testdata/restore_journey_stages.txtar @@ -0,0 +1,23 @@ +# restore journey CLI-shape checks without POSIX sh + +# Backup creation CLI shape +exec bak backup --preset quick +stdout 'Backup created:' +exists .bak/backups + +# Discovery CLI shape: list local backups +exec bak list +stdout 'PRESET' +stdout 'quick' + +# Negative discovery CLI shape: invalid backup ID format exits 1 +! exec bak restore --dry-run invalid-backup-id +stderr 'invalid backup ID' + +# Negative discovery CLI shape: unknown backup ID exits 1 +! exec bak restore --dry-run 20000101-000000 +stderr 'not found' + +# Verify non-existent backup exits 1 +! exec bak verify 20000101-000000 +stderr 'not found' diff --git a/tests/e2e/testdata/undo_after_restore.txtar b/tests/e2e/testdata/undo_after_restore.txtar index edd5bfb..a0868db 100644 --- a/tests/e2e/testdata/undo_after_restore.txtar +++ b/tests/e2e/testdata/undo_after_restore.txtar @@ -1,31 +1,61 @@ -# undo after restore roundtrip -# Sets up a git repository in .bak/ with two commits, runs bak undo, -# and verifies the working tree reverts to the previous state. +# undo after restore roundtrip with real target recovery and drift protection +# Sets up a git repository in .bak/, creates a real backup, modifies targets, +# restores them, tests fail-closed drift protection, and verifies real target revert. # Initialize git repository in .bak/ exec git init .bak exec git -C .bak config user.email test@bak-cli.local exec git -C .bak config user.name bak-test -# First commit: initial state (v1). +# Initial commit in .bak/ so HEAD exists for git revert. cp test-v1.txt .bak/test.txt exec git -C .bak add test.txt exec git -C .bak commit -m bak:initial - -# Second commit: modified state (v2). cp test-v2.txt .bak/test.txt exec git -C .bak add test.txt exec git -C .bak commit -m bak:modified -# Run bak undo β€” should revert the last commit. +# Run real backup with quick preset. +exec bak backup --preset quick +stdout 'Backup created:' + +# Capture the backup ID. +exec sh -c 'ls .bak/backups/ | head -1 > $WORK/bak_id.txt' + +# Modify target AGENTS.md before restore (this will be the pre-restore state). +exec sh -c 'printf "pre-restore-agents\n" > $HOME/.config/opencode/AGENTS.md' + +# Restore with --force to apply backed-up state and create recovery point. +exec sh -c 'ID=$(cat $WORK/bak_id.txt) && bak restore --force $ID' +stdout 'Restore complete' + +# Target was restored to backed-up content (# Agents). +exec grep 'Agents' $HOME/.config/opencode/AGENTS.md + +# --- Drift test case --- +# Mutate target to introduce drift after restore. +exec sh -c 'printf "drifted-agents\n" > $HOME/.config/opencode/AGENTS.md' + +# Undo must fail closed on drift and touch zero files. +! exec bak undo +stderr 'drift' + +# Verify target was not modified by the rejected undo attempt. +exec grep 'drifted-agents' $HOME/.config/opencode/AGENTS.md + +# --- Clean undo case --- +# Align target back to post-restore state (# Agents). +exec sh -c 'printf "# Agents\n" > $HOME/.config/opencode/AGENTS.md' + +# Run bak undo β€” should project pre-state back to targets. exec bak undo stdout 'Reverted to previous state' +stdout 'reverted: ~/.config/opencode/AGENTS.md' -# Verify the working tree file content is back to v1. -exec git -C .bak show HEAD:test.txt -stdout 'v1' +# Verify the real target file was restored to its pre-restore state. +exec grep 'pre-restore-agents' $HOME/.config/opencode/AGENTS.md -# Verify the revert commit message follows bak convention. +# Verify the bak git repository commit was reverted. exec git -C .bak log -1 --format=%s stdout 'bak: undo'