Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ Each `[[job]]` entry defines one sync operation.
| `mode` | string | yes | `"copy"` (add files) or `"sync"` (mirror, may delete) |
| `backup_path` | string | no | Remote path prefix for archived deleted files (sync mode only) |
| `backup_retention_days` | int | no | Days to keep archived files before cleanup |
| `allow_destructive` | bool | no | Required when `mode = "sync"` and `backup_path` is empty. Acknowledges that deleted local files will be permanently removed from the remote on sync. Default: `false`. |
| `filter_file` | string | no | Per-job filter file (overrides default) |

Rclone jobs also accept all tuning fields from `[defaults.rclone]` as per-job overrides (`transfers`, `bwlimit`, etc.).
Expand Down
7 changes: 7 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ delete = true

# Rclone job: sync documents to multiple cloud remotes
# Remotes must already be configured in rclone (run `rclone config`).
# Note: sync mode deletes remote files when local files are removed.
# Either set backup_path (shown here) to archive deletions for later
# recovery, or set allow_destructive = true to opt in to permanent
# deletion.
[[job]]
name = "documents-to-cloud"
engine = "rclone"
Expand All @@ -68,6 +72,9 @@ remotes = ["my_gdrive", "my_s3"]
mode = "sync"
backup_path = "_archive"
backup_retention_days = 365
# allow_destructive = true # required only if backup_path is empty;
# acknowledges that deleted local files will
# be permanently removed from the remote.

# Rclone job: copy media (bandwidth-limited)
[[job]]
Expand Down
27 changes: 26 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ type Job struct {
Mode string `toml:"mode"`
BackupPath string `toml:"backup_path"`
BackupRetentionDays int `toml:"backup_retention_days"`
FilterFile string `toml:"filter_file"`
// AllowDestructive acknowledges that an rclone job with mode="sync"
// and empty backup_path permanently deletes remote files when their
// local counterparts are removed. Required for that configuration to
// pass validation. Ignored on all other configurations (copy mode,
// sync with backup_path set, rsync jobs).
AllowDestructive bool `toml:"allow_destructive"`
FilterFile string `toml:"filter_file"`

// Rclone per-job tuning overrides
Transfers int `toml:"transfers"`
Expand Down Expand Up @@ -346,6 +352,9 @@ func validateRcloneJob(job Job) error {
}
remoteSeen[r] = true
}
if err := validateDestructiveSync(job); err != nil {
return err
}
return validateMaxRuntime(job)
}

Expand All @@ -369,3 +378,19 @@ func validateMaxRuntime(job Job) error {
}
return nil
}

// validateDestructiveSync rejects rclone sync jobs that would permanently
// delete remote files without archival. Passes when mode is not "sync",
// when backup_path is set (deletions are archived), or when the job
// acknowledges the risk via AllowDestructive=true. The error message names
// both resolution paths so the user can fix the config without consulting
// documentation.
func validateDestructiveSync(job Job) error {
if job.Mode != ModeSync || job.BackupPath != "" || job.AllowDestructive {
return nil
}
return fmt.Errorf(
`job %q: mode=%q without backup_path permanently deletes remote files when their local counterparts are removed. Set backup_path to archive deletions, or set allow_destructive=true to acknowledge this`,
job.Name, ModeSync,
)
}
126 changes: 126 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,129 @@ max_runtime = "-1h"
t.Errorf("error %q should mention job name", err.Error())
}
}

func TestValidate_RcloneSyncWithBackupPath_Passes(t *testing.T) {
tomlData := `
[[job]]
name = "docs-safe"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "sync"
backup_path = "_archive"
`
cfg, err := config.LoadBytes([]byte(tomlData))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Jobs[0].AllowDestructive {
t.Error("AllowDestructive = true, want false (zero value)")
}
}

func TestValidate_RcloneSyncNoBackupPath_Rejected(t *testing.T) {
_, err := config.LoadFile(testdataPath("config_sync_no_backup_path.toml"))
if err == nil {
t.Fatal("expected error for sync without backup_path or opt-in, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "unsafe-sync") {
t.Errorf("error %q should mention the job name", msg)
}
if !strings.Contains(msg, "allow_destructive") {
t.Errorf("error %q should mention allow_destructive", msg)
}
if !strings.Contains(msg, "backup_path") {
t.Errorf("error %q should mention backup_path", msg)
}
if !strings.Contains(msg, "mode=") {
t.Errorf("error %q should name the offending mode via the mode= prefix", msg)
}
}

func TestValidate_RcloneSyncAllowDestructive_Passes(t *testing.T) {
cfg, err := config.LoadFile(testdataPath("config_sync_allow_destructive.toml"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.Jobs[0].AllowDestructive {
t.Error("AllowDestructive = false, want true")
}
}

func TestValidate_RcloneCopyNoBackupPath_Passes(t *testing.T) {
tomlData := `
[[job]]
name = "copy-job"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "copy"
`
if _, err := config.LoadBytes([]byte(tomlData)); err != nil {
t.Errorf("copy mode with empty backup_path should pass, got: %v", err)
}
}

func TestValidate_RcloneSyncWithBothBackupPathAndAllowDestructive_Passes(t *testing.T) {
tomlData := `
[[job]]
name = "belt-and-suspenders"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "sync"
backup_path = "_archive"
allow_destructive = true
`
cfg, err := config.LoadBytes([]byte(tomlData))
if err != nil {
t.Fatalf("sync with both backup_path and allow_destructive should pass, got: %v", err)
}
if !cfg.Jobs[0].AllowDestructive {
t.Error("AllowDestructive = false, want true")
}
if cfg.Jobs[0].BackupPath == "" {
t.Error("BackupPath should be set")
}
}

func TestValidate_RsyncJobWithAllowDestructive_Accepted(t *testing.T) {
tomlData := `
[[job]]
name = "rsync-job"
engine = "rsync"
sources = ["/tmp/src"]
destination = "/tmp/dst"
allow_destructive = true
`
cfg, err := config.LoadBytes([]byte(tomlData))
if err != nil {
t.Fatalf("allow_destructive on rsync job should be silently accepted, got: %v", err)
}
if !cfg.Jobs[0].AllowDestructive {
t.Error("AllowDestructive field should still unmarshal to true on rsync jobs")
}
}

func TestValidate_RcloneSyncExplicitFalse_Rejected(t *testing.T) {
// Pins that an explicit `allow_destructive = false` in TOML behaves
// identically to omitting the key. Guards against a future TOML
// library change that could distinguish zero-value from unset.
tomlData := `
[[job]]
name = "explicit-false-sync"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "sync"
allow_destructive = false
`
_, err := config.LoadBytes([]byte(tomlData))
if err == nil {
t.Fatal("expected error for sync with explicit allow_destructive=false, got nil")
}
if !strings.Contains(err.Error(), "allow_destructive") {
t.Errorf("error %q should mention allow_destructive", err.Error())
}
}
7 changes: 7 additions & 0 deletions testdata/config_sync_allow_destructive.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[[job]]
name = "intentional-destructive"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "sync"
allow_destructive = true
6 changes: 6 additions & 0 deletions testdata/config_sync_no_backup_path.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[[job]]
name = "unsafe-sync"
engine = "rclone"
source = "/tmp/docs"
remotes = ["gdrive"]
mode = "sync"
Loading