Merge fix/macos-fda-error-message
All checks were successful
check / check (push) Successful in 2m5s

This commit is contained in:
2026-06-16 05:20:33 -07:00
3 changed files with 73 additions and 4 deletions

View File

@@ -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

View File

@@ -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())
}
}
}

View File

@@ -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