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
9 changes: 7 additions & 2 deletions config/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,11 @@ func TestPathAndTagInRetention(t *testing.T) {
require.Greater(t, len(backupSource), 5)
require.NoError(t, err)

expectedBackupSource := make([]string, len(backupSource))
for index, value := range backupSource {
expectedBackupSource[index] = shell.NewArg(value, shell.ArgConfigEscape).String()
}

backupHost := ""
backupTags := []string{"one", "two"}
flatBackupTags := func() []string { return []string{strings.Join(backupTags, ",")} }
Expand Down Expand Up @@ -820,15 +825,15 @@ func TestPathAndTagInRetention(t *testing.T) {

t.Run("ImplicitCopyPath", func(t *testing.T) {
profile := testProfile(t, Version01, ``)
assert.Equal(t, backupSource, pathFlag(t, profile))
assert.Equal(t, expectedBackupSource, pathFlag(t, profile))
})

t.Run("ExplicitCopyPath", func(t *testing.T) {
expectedIssues := map[string][]string{
`path (from source) "` + sourcePattern + `"`: backupSource,
}
profile := testProfile(t, Version01, `path = true`)
assert.Equal(t, backupSource, pathFlag(t, profile))
assert.Equal(t, expectedBackupSource, pathFlag(t, profile))
assert.Equal(t, expectedIssues, profile.config.issues.changedPaths)

profile.config.DisplayConfigurationIssues()
Expand Down
33 changes: 33 additions & 0 deletions examples/other windows.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# yaml-language-server: $schema=https://creativeprojects.github.io/resticprofile/jsonschema/config.json

version: "1"

global:
priority: low
min-memory: 10
prevent-sleep: true


default:
repository: "c:\\backup"
password-file: key
lock: "c:\\resticprofile-{{ .Profile.Name }}.lock"

self:
inherit: default
verbose: true
force-inactive-lock: true
status-file: "c:\\backup\\status.json"
backup:
source: "."
exclude:
- "*.exe"
- "resticprofile_*"
- ".git"
- "examples\\private"
schedule:
at:
- "Mon..Fri *:00,15,30,45" # every 15 minutes on weekdays
- "Sat,Sun 0,12:00" # twice a day on week-ends
permission: user_logged_on
log: "self-backup.log"
72 changes: 72 additions & 0 deletions schtasks/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//go:build windows

package schtasks

import (
"bufio"
"errors"
"io"
"strings"
)

const (
commonListSeparator = ": "
otherListSeparator = ": "
listFolderKey = "Folder"
)

func getTaskInfoFromList(input io.Reader) ([]map[string]string, error) {
currentFolder := ""
output := make([]map[string]string, 0, 10)
record := make(map[string]string, 30)
scanner := bufio.NewScanner(input)
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
if len(record) > 0 {
record[listFolderKey] = currentFolder
output = append(output, record)
record = make(map[string]string, 30)
}
continue
}
key, value, found := strings.Cut(line, commonListSeparator)
if !found {
// if the line doesn't contain a colon followed by 2 spaces, it means it's either "Folder:" or the longest key.
key, value, found = strings.Cut(line, otherListSeparator)
if !found {
return output, errors.New("invalid line format: " + line)
}
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if len(key) == 0 || len(value) == 0 {
continue
}
if key == listFolderKey {
currentFolder = value
continue
}
if _, exists := record[key]; exists {
return output, errors.New("duplicate key found in task info: " + key)
}
record[key] = value
}
if err := scanner.Err(); err != nil {
return output, err
}
if len(record) > 0 {
record[listFolderKey] = currentFolder
output = append(output, record)
}
return output, nil
}

func getFirstField(data []map[string]string, fieldName string) string {
for _, record := range data {
if value, exists := record[fieldName]; exists {
return value
}
}
return ""
}
191 changes: 191 additions & 0 deletions schtasks/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//go:build windows

package schtasks

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

//nolint:misspell
const listOutput = `
Folder: \resticprofile backup
HostName: WIN-5NMJF0VS8OR
TaskName: \resticprofile backup\self backup
Next Run Time: 04/08/2025 21:15:00
Status: Ready
Logon Mode: Interactive only
Last Run Time: 04/08/2025 21:00:01
Last Result: 1
Author: resticprofile
Task To Run: G:\go\src\github.com\creativeprojects\resticprofile\resticprofile.exe --no-ansi --config "examples\other windows.yaml" run-schedule backup@self
Start In: G:\go\src\github.com\creativeprojects\resticprofile
Comment: resticprofile backup for profile self in examples\other windows.yaml
Scheduled Task State: Enabled
Idle Time: Disabled
Power Management: Stop On Battery Mode, No Start On Batteries
Run As User: Fred
Delete Task If Not Rescheduled: Disabled
Stop Task If Runs X Hours and X Mins: Disabled
Schedule: Scheduling data is not available in this format.
Schedule Type: Weekly
Start Time: 20:15:00
Start Date: 04/08/2025
End Date: N/A
Days: MON, TUE, WED, THU, FRI
Months: Every 1 week(s)
Repeat: Every: 0 Hour(s), 15 Minute(s)
Repeat: Until: Time: None
Repeat: Until: Duration: 23 Hour(s), 45 Minute(s)
Repeat: Stop If Still Running: Disabled

HostName: WIN-5NMJF0VS8OR
TaskName: \resticprofile backup\self backup
Next Run Time: 04/08/2025 21:15:00
Status: Ready
Logon Mode: Interactive only
Last Run Time: 04/08/2025 21:00:01
Last Result: 1
Author: resticprofile
Task To Run: G:\go\src\github.com\creativeprojects\resticprofile\resticprofile.exe --no-ansi --config "examples\other windows.yaml" run-schedule backup@self
Start In: G:\go\src\github.com\creativeprojects\resticprofile
Comment: resticprofile backup for profile self in examples\other windows.yaml
Scheduled Task State: Enabled
Idle Time: Disabled
Power Management: Stop On Battery Mode, No Start On Batteries
Run As User: Fred
Delete Task If Not Rescheduled: Disabled
Stop Task If Runs X Hours and X Mins: Disabled
Schedule: Scheduling data is not available in this format.
Schedule Type: Weekly
Start Time: 00:00:00
Start Date: 09/08/2025
End Date: N/A
Days: SUN, SAT
Months: Every 1 week(s)
Repeat: Every: 12 Hour(s), 0 Minute(s)
Repeat: Until: Time: None
Repeat: Until: Duration: 12 Hour(s), 0 Minute(s)
Repeat: Stop If Still Running: Disabled
`

func TestReadingListOutput(t *testing.T) {
t.Parallel()

output, err := getTaskInfoFromList(bytes.NewBufferString(listOutput))
require.NoError(t, err)
assert.NotNil(t, output)

//nolint:misspell
expected := []map[string]string{
{
"Folder": "\\resticprofile backup",
"HostName": "WIN-5NMJF0VS8OR",
"TaskName": "\\resticprofile backup\\self backup",
"Next Run Time": "04/08/2025 21:15:00",
"Status": "Ready",
"Logon Mode": "Interactive only",
"Last Run Time": "04/08/2025 21:00:01",
"Last Result": "1",
"Author": "resticprofile",
"Task To Run": "G:\\go\\src\\github.com\\creativeprojects\\resticprofile\\resticprofile.exe --no-ansi --config \"examples\\other windows.yaml\" run-schedule backup@self",
"Start In": "G:\\go\\src\\github.com\\creativeprojects\\resticprofile",
"Comment": "resticprofile backup for profile self in examples\\other windows.yaml",
"Scheduled Task State": "Enabled",
"Idle Time": "Disabled",
"Power Management": "Stop On Battery Mode, No Start On Batteries",
"Run As User": "Fred",
"Delete Task If Not Rescheduled": "Disabled",
"Stop Task If Runs X Hours and X Mins": "Disabled",
"Schedule": "Scheduling data is not available in this format.",
"Schedule Type": "Weekly",
"Start Time": "20:15:00",
"Start Date": "04/08/2025",
"End Date": "N/A",
"Days": "MON, TUE, WED, THU, FRI",
"Months": "Every 1 week(s)",
"Repeat: Every": "0 Hour(s), 15 Minute(s)",
"Repeat: Until: Time": "None",
"Repeat: Until: Duration": "23 Hour(s), 45 Minute(s)",
"Repeat: Stop If Still Running": "Disabled",
},
{
"Folder": "\\resticprofile backup",
"HostName": "WIN-5NMJF0VS8OR",
"TaskName": "\\resticprofile backup\\self backup",
"Next Run Time": "04/08/2025 21:15:00",
"Status": "Ready",
"Logon Mode": "Interactive only",
"Last Run Time": "04/08/2025 21:00:01",
"Last Result": "1",
"Author": "resticprofile",
"Task To Run": "G:\\go\\src\\github.com\\creativeprojects\\resticprofile\\resticprofile.exe --no-ansi --config \"examples\\other windows.yaml\" run-schedule backup@self",
"Start In": "G:\\go\\src\\github.com\\creativeprojects\\resticprofile",
"Comment": "resticprofile backup for profile self in examples\\other windows.yaml",
"Scheduled Task State": "Enabled",
"Idle Time": "Disabled",
"Power Management": "Stop On Battery Mode, No Start On Batteries",
"Run As User": "Fred",
"Delete Task If Not Rescheduled": "Disabled",
"Stop Task If Runs X Hours and X Mins": "Disabled",
"Schedule": "Scheduling data is not available in this format.",
"Schedule Type": "Weekly",
"Start Time": "00:00:00",
"Start Date": "09/08/2025",
"End Date": "N/A",
"Days": "SUN, SAT",
"Months": "Every 1 week(s)",
"Repeat: Every": "12 Hour(s), 0 Minute(s)",
"Repeat: Until: Time": "None",
"Repeat: Until: Duration": "12 Hour(s), 0 Minute(s)",
"Repeat: Stop If Still Running": "Disabled",
},
}
assert.Equal(t, expected, output)
}

func TestReadingListOutputWithNoSeparator(t *testing.T) {
t.Parallel()

list := `
Line with no separator
`
_, err := getTaskInfoFromList(bytes.NewBufferString(list))
require.Error(t, err)
}

func TestReadingListOutputWithEmptyKey(t *testing.T) {
t.Parallel()

list := `
: Value
`
_, err := getTaskInfoFromList(bytes.NewBufferString(list))
require.NoError(t, err)
}

func TestReadingListOutputWithEmptyValue(t *testing.T) {
t.Parallel()

// keep the space after the colon
list := `
Key:
`
_, err := getTaskInfoFromList(bytes.NewBufferString(list))
require.NoError(t, err)
}

func TestReadingListOutputWithDuplicateKey(t *testing.T) {
t.Parallel()

// keep the space after the colon
list := `
Key: Value
Key: Value
`
_, err := getTaskInfoFromList(bytes.NewBufferString(list))
require.Error(t, err)
}
12 changes: 6 additions & 6 deletions schtasks/schtasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func getRegisteredTasks() ([]string, error) {
if err != nil {
return nil, err
}
all, err := getCSV(bytes.NewBuffer(raw))
all, err := getTaskInfoFromCSV(bytes.NewBuffer(raw))
if err != nil {
return nil, err
}
Expand All @@ -33,20 +33,20 @@ func getRegisteredTasks() ([]string, error) {
return list, nil
}

func getTaskInfo(taskName string) ([][]string, error) {
func getTaskInfo(taskName string) ([]map[string]string, error) {
buffer := &bytes.Buffer{}
err := readTaskInfo(taskName, buffer)
if err != nil {
return nil, err
}
output, err := getCSV(buffer)
output, err := getTaskInfoFromList(buffer)
if err != nil {
return nil, err
}
return output, nil
}

func getCSV(input io.Reader) ([][]string, error) {
func getTaskInfoFromCSV(input io.Reader) ([][]string, error) {
reader := csv.NewReader(input)
return reader.ReadAll()
}
Expand Down Expand Up @@ -124,14 +124,14 @@ func deleteTask(taskName string) (string, error) {
return stdout.String(), nil
}

// readTaskInfo returns the raw CSV output from querying the task name (via schtasks.exe)
// readTaskInfo returns the raw output from querying the task name (via schtasks.exe)
func readTaskInfo(taskName string, output io.Writer) error {
taskName, err := sanitizeTaskName(taskName)
if err != nil {
return err
}
stderr := &bytes.Buffer{}
cmd := exec.Command(binaryPath, "/query", "/fo", "csv", "/v", "/tn", taskName)
cmd := exec.Command(binaryPath, "/query", "/fo", "list", "/v", "/tn", taskName)
cmd.Stdout = output
cmd.Stderr = stderr
err = cmd.Run()
Expand Down
Loading
Loading