Repository: creativeprojects/resticprofile (language: python)

Issue:
paths with spaces not quoted in Task Scheduler task on Windows
I am running the latest version of resticprofile (0.23.0).

I was trying to figure out why the task in Task Scheduler generated by resticprofile kept failing. It turns out the paths to a config file and a log file in the command arguments of the task contained spaces and were not quoted. Adding the quotes manually fixed the issue.

This occurs both for the 'backup' and 'check' task.

The arguments generated for the backup task were:

`--no-ansi --config C:\Users\<my username with spaces>\AppData\Roaming\resticprofile\profiles.yaml --name default --log C:\Users\<my username with spaces>\AppData\Roaming\resticprofile\default-backup.log backup`

while they should have been:

`--no-ansi --config "C:\Users\<my username with spaces>\AppData\Roaming\resticprofile\profiles.yaml" --name default --log "C:\Users\<my username with spaces>\AppData\Roaming\resticprofile\default-backup.log" backup`


Discussion / hints:
You're right, the path is not escaped.

At the same time I also noticed it's not escaped when trying to elevate resticprofile to admin privileges.


Fix patch:
diff --git a/schedule_jobs.go b/schedule_jobs.go
index 3bf9ff91..892e5104 100644
--- a/schedule_jobs.go
+++ b/schedule_jobs.go
@@ -33,7 +33,7 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi
 		args := []string{
 			"--no-ansi",
 			"--config",
-			scheduleConfig.ConfigFile,
+			`"` + scheduleConfig.ConfigFile + `"`,
 			"run-schedule",
 			scheduleName,
 		}
diff --git a/shell/command.go b/shell/command.go
index deac69a9..2ad2765b 100644
--- a/shell/command.go
+++ b/shell/command.go
@@ -177,7 +177,8 @@ func (c *Command) Run() (monitor.Summary, string, error) {
 	return summary, errorText, err
 }
 
-// GetShellCommand transforms the command line and arguments to be launched via a shell (sh or cmd.exe)
+// GetShellCommand transforms the command line and arguments to be launched via a shell (sh or cmd.exe).
+// This method doesn't escape any argument containing spaces, it should have been dealt with before.
 func (c *Command) GetShellCommand() (shell string, arguments []string, err error) {
 	var searchList []string
 	for _, sh := range c.Shell {
diff --git a/win/windows.go b/win/windows.go
index 078ea485..853b577e 100644
--- a/win/windows.go
+++ b/win/windows.go
@@ -23,7 +23,7 @@ func RunElevated(port int) error {
 		constants.FlagAsChild,
 		constants.FlagPort,
 		port,
-		strings.Join(os.Args[1:], " "),
+		parseArguments(os.Args[1:]),
 	)
 
 	verbPtr, _ := syscall.UTF16PtrFromString(verb)
@@ -49,3 +49,18 @@ func GetConsoleWindow() windows.Handle {
 	ret, _, _ := getConsoleWindow.Call()
 	return windows.Handle(ret)
 }
+
+// parseArguments takes a slice of strings as input and returns a single string.
+// It processes each argument, and if an argument contains a space, it wraps it in double quotes.
+// Finally, it joins all the processed arguments into a single string separated by spaces.
+func parseArguments(args []string) string {
+	output := make([]string, len(args))
+	for i, arg := range args {
+		if strings.Contains(arg, " ") {
+			output[i] = `"` + arg + `"`
+			continue
+		}
+		output[i] = arg
+	}
+	return strings.Join(output, " ")
+}
diff --git a/wrapper.go b/wrapper.go
index 2a94e389..8ede831c 100644
--- a/wrapper.go
+++ b/wrapper.go
@@ -435,7 +435,10 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx
 		return fmt.Sprintf("starting command: %s %s%s", r.ctx.binary, strings.Join(publicArguments, " "), wd)
 	})
 
-	rCommand := newShellCommand(r.ctx.binary, arguments, env, r.getShell(), r.dryRun, r.sigChan, r.setPID)
+	// creates an argument to escape the path properly
+	binary := shell.NewArg(r.ctx.binary, shell.ArgConfigEscape).String()
+
+	rCommand := newShellCommand(binary, arguments, env, r.getShell(), r.dryRun, r.sigChan, r.setPID)
 	rCommand.publicArgs = publicArguments
 	// stdout are stderr are coming from the default terminal (in case they're redirected)
 	rCommand.stdout = term.GetOutput()


Test patch:
diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go
index 62d9db6f..98ceb852 100644
--- a/schedule_jobs_test.go
+++ b/schedule_jobs_test.go
@@ -42,11 +42,12 @@ func TestSimpleScheduleJob(t *testing.T) {
 		mock.AnythingOfType("[]*calendar.Event"),
 		mock.AnythingOfType("string")).
 		RunAndReturn(func(scheduleConfig *schedule.Config, events []*calendar.Event, permission string) error {
-			assert.Equal(t, []string{"--no-ansi", "--config", "", "run-schedule", "backup@profile"}, scheduleConfig.Arguments)
+			assert.Equal(t, []string{"--no-ansi", "--config", `"config.file"`, "run-schedule", "backup@profile"}, scheduleConfig.Arguments)
 			return nil
 		})
 
 	scheduleConfig := configForJob("backup", "sched")
+	scheduleConfig.ConfigFile = "config.file"
 	err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig})
 	assert.NoError(t, err)
 }
diff --git a/shell/command_test.go b/shell/command_test.go
index 09048900..de22f013 100644
--- a/shell/command_test.go
+++ b/shell/command_test.go
@@ -61,7 +61,7 @@ func TestRemoveQuotes(t *testing.T) {
 func TestShellCommandWithArguments(t *testing.T) {
 	t.Parallel()
 
-	testCommand := "/bin/restic"
+	testCommand := `"/bin/with space/restic"`
 	testArgs := []string{
 		`-v`,
 		`--exclude-file`,
@@ -82,7 +82,7 @@ func TestShellCommandWithArguments(t *testing.T) {
 		assert.Equal(t, `c:\windows\system32\cmd.exe`, strings.ToLower(command))
 		assert.Equal(t, []string{
 			`/V:ON`, `/C`,
-			`/bin/restic`,
+			`"/bin/with space/restic"`,
 			`-v`,
 			`--exclude-file`,
 			`excludes`,
@@ -95,7 +95,7 @@ func TestShellCommandWithArguments(t *testing.T) {
 		assert.Regexp(t, regexp.MustCompile("(/usr)?/bin/(ba)?sh"), command)
 		assert.Equal(t, []string{
 			"-c",
-			"/bin/restic -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
+			"\"/bin/with space/restic\" -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
 		}, args)
 	}
 }
@@ -103,7 +103,7 @@ func TestShellCommandWithArguments(t *testing.T) {
 func TestShellCommand(t *testing.T) {
 	t.Parallel()
 
-	testCommand := "/bin/restic -v --exclude-file \"excludes\" --repo \"/path/with space\" backup ."
+	testCommand := "\"/bin/with space/restic\" -v --exclude-file \"excludes\" --repo \"/path/with space\" backup ."
 	testArgs := []string{}
 	c := &Command{
 		Command:   testCommand,
@@ -116,13 +116,13 @@ func TestShellCommand(t *testing.T) {
 		assert.Equal(t, `c:\windows\system32\cmd.exe`, strings.ToLower(command))
 		assert.Equal(t, []string{
 			"/V:ON", "/C",
-			"/bin/restic -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
+			"\"/bin/with space/restic\" -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
 		}, args)
 	} else {
 		assert.Regexp(t, regexp.MustCompile("(/usr)?/bin/(ba)?sh"), command)
 		assert.Equal(t, []string{
 			"-c",
-			"/bin/restic -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
+			"\"/bin/with space/restic\" -v --exclude-file \"excludes\" --repo \"/path/with space\" backup .",
 		}, args)
 	}
 }
diff --git a/win/windows_test.go b/win/windows_test.go
new file mode 100644
index 00000000..adb62012
--- /dev/null
+++ b/win/windows_test.go
@@ -0,0 +1,50 @@
+//go:build windows
+
+package win
+
+import (
+	"testing"
+)
+
+func TestParseArguments(t *testing.T) {
+	tests := []struct {
+		name     string
+		args     []string
+		expected string
+	}{
+		{
+			name:     "NoSpaces",
+			args:     []string{"arg1", "arg2", "arg3"},
+			expected: "arg1 arg2 arg3",
+		},
+		{
+			name:     "WithSpaces",
+			args:     []string{"arg1", "arg 2", "arg3"},
+			expected: `arg1 "arg 2" arg3`,
+		},
+		{
+			name:     "AllWithSpaces",
+			args:     []string{"arg 1", "arg 2", "arg 3"},
+			expected: `"arg 1" "arg 2" "arg 3"`,
+		},
+		{
+			name:     "EmptyArgs",
+			args:     []string{},
+			expected: "",
+		},
+		{
+			name:     "MixedArgs",
+			args:     []string{"arg1", "arg 2", "arg3", "arg 4"},
+			expected: `arg1 "arg 2" arg3 "arg 4"`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := parseArguments(tt.args)
+			if result != tt.expected {
+				t.Errorf("parseArguments(%v) = %v; expected %v", tt.args, result, tt.expected)
+			}
+		})
+	}
+}
diff --git a/wrapper_test.go b/wrapper_test.go
index 8fdd38b7..3313fa76 100644
--- a/wrapper_test.go
+++ b/wrapper_test.go
@@ -1728,3 +1728,21 @@ func TestCopySnapshot(t *testing.T) {
 	cmd := wrapper.prepareCommand("copy", args, false)
 	assert.Equal(t, []string{"copy", "snapshot1", "snapshot2"}, cmd.args)
 }
+
+func TestPrepareCommandShouldEscapeBinary(t *testing.T) {
+	if platform.IsWindows() {
+		t.Skip("not supported on Windows")
+	}
+	t.Parallel()
+
+	profile := config.NewProfile(&config.Config{}, "name")
+	ctx := &Context{
+		binary:  "/full path to/restic",
+		profile: profile,
+		command: "backup",
+	}
+	wrapper := newResticWrapper(ctx)
+	args := shell.NewArgs()
+	cmd := wrapper.prepareCommand("backup", args, false)
+	assert.Equal(t, `/full\ path\ to/restic`, cmd.command)
+}