Add actionable permission-error message with macOS Full Disk Access hint
When the scanner hits a permission-denied error (TCC-protected directories on macOS without Full Disk Access, or any other EPERM), the error now names the offending path and includes platform-specific remediation instructions. On macOS it points the user at System Settings -> Privacy & Security -> Full Disk Access. On other platforms it suggests --skip-errors. The error wraps os.ErrPermission so errors.Is still works for callers that care about the underlying error. README quickstart and snapshot create docs now mention the macOS FDA requirement.
This commit is contained in:
12
README.md
12
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 age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
|
||||||
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
|
||||||
|
|
||||||
# back up your home directory (the default config includes a "home"
|
# macOS only: grant your terminal app Full Disk Access first
|
||||||
# snapshot of ~ with sensible excludes)
|
# (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
|
vaultik snapshot create
|
||||||
|
|
||||||
# see what you have
|
# 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.
|
**`snapshot create`**: Perform incremental backup of configured snapshots.
|
||||||
* Optional snapshot names argument to create specific snapshots (default: all)
|
* 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)
|
* `--cron`: Silent unless error (for crontab)
|
||||||
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
* `--prune`: After backup, drop older snapshots of each backed-up name and
|
||||||
remove orphaned blobs from remote storage. By default keeps only the latest
|
remove orphaned blobs from remote storage. By default keeps only the latest
|
||||||
|
|||||||
42
internal/snapshot/permission_error_test.go
Normal file
42
internal/snapshot/permission_error_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -631,7 +632,7 @@ func (s *Scanner) scanPhase(ctx context.Context, path string, result *ScanResult
|
|||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
log.Debug("Error accessing filesystem entry", "path", filePath, "error", err)
|
log.Debug("Error accessing filesystem entry", "path", filePath, "error", err)
|
||||||
return err
|
return wrapPermissionError(filePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context cancellation
|
// Check context cancellation
|
||||||
@@ -1290,7 +1291,7 @@ func (s *Scanner) processFileStreaming(ctx context.Context, fileToProcess *FileT
|
|||||||
|
|
||||||
file, err := s.fs.Open(fileToProcess.Path)
|
file, err := s.fs.Open(fileToProcess.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("opening file: %w", err)
|
return fmt.Errorf("opening file: %w", wrapPermissionError(fileToProcess.Path, err))
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
@@ -1447,6 +1448,24 @@ func (s *Scanner) detectDeletedFilesFromMap(ctx context.Context, knownFiles map[
|
|||||||
return nil
|
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
|
// compileExcludePatterns compiles the exclude patterns into glob matchers
|
||||||
func compileExcludePatterns(patterns []string) []compiledPattern {
|
func compileExcludePatterns(patterns []string) []compiledPattern {
|
||||||
var compiled []compiledPattern
|
var compiled []compiledPattern
|
||||||
|
|||||||
Reference in New Issue
Block a user