diff --git a/README.md b/README.md index e28c048..47ec901 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,12 @@ grep 'public key' vaultik_backup_private_key.txt vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE vaultik config set storage_url "file:///Volumes/usbstick/mybackup" -# back up your home directory (the default config includes a "home" -# snapshot of ~ with sensible excludes) +# macOS only: grant your terminal app Full Disk Access first +# (System Settings → Privacy & Security → Full Disk Access), otherwise +# the backup will abort with a permission error on protected directories + +# run your first backup (the default config backs up ~ and /Applications +# with sensible excludes) vaultik snapshot create # see what you have @@ -159,6 +163,10 @@ in the file are preserved; intermediate maps are created as needed. **`snapshot create`**: Perform incremental backup of configured snapshots. * Optional snapshot names argument to create specific snapshots (default: all) +* On macOS, the terminal application running vaultik needs Full Disk Access + (System Settings → Privacy & Security → Full Disk Access) to read + TCC-protected directories; without it the backup aborts with a permission + error that explains how to fix it * `--cron`: Silent unless error (for crontab) * `--prune`: After backup, drop older snapshots of each backed-up name and remove orphaned blobs from remote storage. By default keeps only the latest diff --git a/internal/snapshot/permission_error_test.go b/internal/snapshot/permission_error_test.go new file mode 100644 index 0000000..bbde682 --- /dev/null +++ b/internal/snapshot/permission_error_test.go @@ -0,0 +1,42 @@ +package snapshot + +import ( + "errors" + "fmt" + "os" + "runtime" + "strings" + "testing" +) + +func TestWrapPermissionError(t *testing.T) { + // Non-permission errors pass through unchanged. + plain := errors.New("disk on fire") + if got := wrapPermissionError("/some/path", plain); got != plain { + t.Errorf("non-permission error should pass through, got %v", got) + } + + // Permission errors get remediation instructions. + permErr := fmt.Errorf("open /x: %w", os.ErrPermission) + wrapped := wrapPermissionError("/Users/u/Library/Calendars", permErr) + + if !errors.Is(wrapped, os.ErrPermission) { + t.Error("wrapped error should still match os.ErrPermission") + } + if !strings.Contains(wrapped.Error(), "/Users/u/Library/Calendars") { + t.Error("wrapped error should name the offending path") + } + + if runtime.GOOS == "darwin" { + if !strings.Contains(wrapped.Error(), "Full Disk Access") { + t.Errorf("macOS permission error should mention Full Disk Access:\n%s", wrapped.Error()) + } + if !strings.Contains(wrapped.Error(), "System Settings") { + t.Errorf("macOS permission error should point at System Settings:\n%s", wrapped.Error()) + } + } else { + if !strings.Contains(wrapped.Error(), "--skip-errors") { + t.Errorf("non-macOS permission error should mention --skip-errors:\n%s", wrapped.Error()) + } + } +} diff --git a/internal/snapshot/scanner.go b/internal/snapshot/scanner.go index 4102a43..320b96a 100644 --- a/internal/snapshot/scanner.go +++ b/internal/snapshot/scanner.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "sync" "time" @@ -631,7 +632,7 @@ func (s *Scanner) scanPhase(ctx context.Context, path string, result *ScanResult return nil // Continue scanning } log.Debug("Error accessing filesystem entry", "path", filePath, "error", err) - return err + return wrapPermissionError(filePath, err) } // Check context cancellation @@ -1290,7 +1291,7 @@ func (s *Scanner) processFileStreaming(ctx context.Context, fileToProcess *FileT file, err := s.fs.Open(fileToProcess.Path) if err != nil { - return fmt.Errorf("opening file: %w", err) + return fmt.Errorf("opening file: %w", wrapPermissionError(fileToProcess.Path, err)) } defer func() { _ = file.Close() }() @@ -1447,6 +1448,24 @@ func (s *Scanner) detectDeletedFilesFromMap(ctx context.Context, knownFiles map[ return nil } +// wrapPermissionError augments permission errors with platform-specific +// remediation instructions. On macOS, TCC-protected directories (Calendars, +// Reminders, Photos, etc.) return EPERM unless the running application has +// been granted Full Disk Access. +func wrapPermissionError(path string, err error) error { + if !errors.Is(err, os.ErrPermission) { + return err + } + if runtime.GOOS == "darwin" { + return fmt.Errorf("cannot read %s: %w\n\n"+ + "macOS is blocking access to this path. Grant Full Disk Access to your\n"+ + "terminal application (or the app running vaultik):\n\n"+ + " System Settings → Privacy & Security → Full Disk Access\n\n"+ + "then quit and reopen the terminal and re-run the backup", path, err) + } + return fmt.Errorf("cannot read %s: %w (check file permissions, or run with --skip-errors to continue past unreadable files)", path, err) +} + // compileExcludePatterns compiles the exclude patterns into glob matchers func compileExcludePatterns(patterns []string) []compiledPattern { var compiled []compiledPattern