Merge fix/macos-fda-error-message
All checks were successful
check / check (push) Successful in 2m5s
All checks were successful
check / check (push) Successful in 2m5s
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