From 91d76acfe1a851e892eec3320338b591f96de855 Mon Sep 17 00:00:00 2001 From: mayerwin Date: Mon, 29 Jun 2026 22:24:13 -1000 Subject: [PATCH] fix(restore): restore single file with [ or ] in name on Windows (#1254) --- internal/orchestrator/repo/repo.go | 21 ++++++++++++++++++++- internal/orchestrator/repo/repo_test.go | 15 +++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/internal/orchestrator/repo/repo.go b/internal/orchestrator/repo/repo.go index 5dcdafc7..384b7af1 100644 --- a/internal/orchestrator/repo/repo.go +++ b/internal/orchestrator/repo/repo.go @@ -454,9 +454,28 @@ func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) { var globEscapeReplacer = strings.NewReplacer(`\`, `\\`, `*`, `\*`, `?`, `\?`, `[`, `\[`, `]`, `\]`) +// On Windows, restic's --include uses Go's path/filepath.Match, which treats +// '\' as a path separator (not an escape character) and rejects the POSIX +// class self-escape `[[]` / `[]]` as an invalid pattern. There is therefore +// no way to express a literal '[' or ']' as a match character on Windows. +// +// NTFS forbids '*' and '?' in real filenames, so the single-character +// wildcard '?' is safe to use as a stand-in for '[' and ']' in a leaf-name +// pattern: it can never collide with a sibling whose name is exactly the +// requested file. It may, however, match a same-length sibling whose name +// differs only in the position of the substituted bracket — typically zero, +// occasionally one or two real-world siblings. +// +// Trade-off: the prior behaviour silently restored zero files when the +// requested leaf had a bracket in its name (matches no snapshot path). +// With this fix the requested file is restored; same-length siblings may +// also land in the target dir. A caller that wants exact-set semantics +// should sweep the target after restore against the requested path list. +var windowsBracketReplacer = strings.NewReplacer(`[`, `?`, `]`, `?`) + func escapeGlob(s string) string { if runtime.GOOS == "windows" { - return s // escaping is not supported on Windows + return windowsBracketReplacer.Replace(s) } return globEscapeReplacer.Replace(s) } diff --git a/internal/orchestrator/repo/repo_test.go b/internal/orchestrator/repo/repo_test.go index 42ccbe8a..6f9094c9 100644 --- a/internal/orchestrator/repo/repo_test.go +++ b/internal/orchestrator/repo/repo_test.go @@ -97,10 +97,13 @@ func TestBackup(t *testing.T) { func TestRestore(t *testing.T) { t.Parallel() - // Use a filepath that exercises a few of the glob characters to test escaping - messyFilePathToTestGlobs := "test.txt" - if runtime.GOOS != "windows" { - messyFilePathToTestGlobs = "test*?[].txt" + // Use a filepath that exercises a few of the glob characters to test escaping. + // On Windows, '*' and '?' are forbidden in filenames, so the messy name only + // contains the brackets — '[' and ']' are legal in NTFS and are the chars + // the Windows path of escapeGlob has to handle without backslash escape. + messyFilePathToTestGlobs := "test*?[].txt" + if runtime.GOOS == "windows" { + messyFilePathToTestGlobs = "test[brackets].txt" } testFile := path.Join(t.TempDir(), messyFilePathToTestGlobs) @@ -144,10 +147,6 @@ func TestRestore(t *testing.T) { } t.Logf("restore summary: %+v", restoreSummary) - if runtime.GOOS == "windows" { - return - } - if restoreSummary.FilesRestored != 1 { t.Errorf("expected 1 new file, got %d", restoreSummary.FilesRestored) }