From bee1ede6544e4bbaccb6e521b5656c175a66fe7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Mon, 7 Sep 2026 19:24:43 +0800 Subject: [PATCH 1/4] fix: limit special directory detection to template actions --- internal/filepathext/filepathext.go | 18 +++++++--- internal/filepathext/filepathext_test.go | 45 ++++++++++++++++++++++++ taskfile/node_file_test.go | 33 +++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 internal/filepathext/filepathext_test.go create mode 100644 taskfile/node_file_test.go diff --git a/internal/filepathext/filepathext.go b/internal/filepathext/filepathext.go index f2a1ba15a0..947d52b927 100644 --- a/internal/filepathext/filepathext.go +++ b/internal/filepathext/filepathext.go @@ -32,12 +32,22 @@ var knownAbsDirs = []string{ } func isSpecialDir(dir string) bool { - for _, d := range knownAbsDirs { - if strings.Contains(dir, d) { - return true + for { + _, action, ok := strings.Cut(dir, "{{") + if !ok { + return false + } + action, dir, ok = strings.Cut(action, "}}") + if !ok { + return false + } + // Only inspect template actions, not literal path components. + for _, d := range knownAbsDirs { + if strings.Contains(action, d) { + return true + } } } - return false } // TryAbsToRel tries to convert an absolute path to relative based on the diff --git a/internal/filepathext/filepathext_test.go b/internal/filepathext/filepathext_test.go new file mode 100644 index 0000000000..9de492fb14 --- /dev/null +++ b/internal/filepathext/filepathext_test.go @@ -0,0 +1,45 @@ +package filepathext + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmartJoinRelativePathContainingSpecialVariableName(t *testing.T) { + t.Parallel() + + base := t.TempDir() + for _, variable := range []string{".ROOT_DIR", ".TASKFILE_DIR", ".USER_WORKING_DIR"} { + t.Run(variable, func(t *testing.T) { + t.Parallel() + for _, relative := range []string{ + filepath.Join("project"+variable, "file.txt"), + filepath.Join(variable, "file.txt"), + filepath.Join("{{.PROJECT}}"+variable, "file.txt"), + } { + require.False(t, IsAbs(relative), relative) + require.Equal(t, filepath.Join(base, relative), SmartJoin(base, relative)) + } + }) + } +} + +func TestSmartJoinAbsoluteAndTemplatePaths(t *testing.T) { + t.Parallel() + + base := t.TempDir() + absolute := filepath.Join(t.TempDir(), "file.txt") + for _, path := range []string{ + absolute, + "{{.ROOT_DIR}}/file.txt", + "{{ .TASKFILE_DIR }}/file.txt", + "{{- .USER_WORKING_DIR -}}/file.txt", + "{{.ROOT_DIR | toSlash}}/file.txt", + "{{.PROJECT}}/{{.ROOT_DIR}}/file.txt", + } { + require.True(t, IsAbs(path), path) + require.Equal(t, path, SmartJoin(base, path)) + } +} diff --git a/taskfile/node_file_test.go b/taskfile/node_file_test.go new file mode 100644 index 0000000000..8f8552d550 --- /dev/null +++ b/taskfile/node_file_test.go @@ -0,0 +1,33 @@ +package taskfile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFileNodeResolveLiteralSpecialDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + entrypoint := filepath.Join(dir, "Taskfile.yml") + require.NoError(t, os.WriteFile(entrypoint, []byte("version: '3'\n"), 0o600)) + node, err := NewFileNode(entrypoint, "") + require.NoError(t, err) + + for _, name := range []string{"project.ROOT_DIR", "project.TASKFILE_DIR", "project.USER_WORKING_DIR"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resolvedDir, err := node.ResolveDir(name) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, name), resolvedDir) + + resolvedEntrypoint, err := node.ResolveEntrypoint(filepath.Join(name, "Taskfile.yml")) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, name, "Taskfile.yml"), resolvedEntrypoint) + }) + } +} From 7f7b87f31d8c51aa64db2e312f2caac77beccf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 8 Sep 2026 17:42:22 +0800 Subject: [PATCH 2/4] refactor: precompile special directory template matching --- internal/filepathext/filepathext.go | 26 ++++-------------------- internal/filepathext/filepathext_test.go | 19 +++++++++++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/internal/filepathext/filepathext.go b/internal/filepathext/filepathext.go index 947d52b927..23356fc193 100644 --- a/internal/filepathext/filepathext.go +++ b/internal/filepathext/filepathext.go @@ -3,7 +3,7 @@ package filepathext import ( "os" "path/filepath" - "strings" + "regexp" ) // SmartJoin joins two paths, but only if the second is not already an @@ -25,29 +25,11 @@ func IsAbs(path string) bool { return filepath.IsAbs(path) } -var knownAbsDirs = []string{ - ".ROOT_DIR", - ".TASKFILE_DIR", - ".USER_WORKING_DIR", -} +// Match special directory variables only within a template action. +var specialDirRE = regexp.MustCompile(`\{\{[^{}]*\.(?:ROOT_DIR|TASKFILE_DIR|USER_WORKING_DIR)[^{}]*\}\}`) func isSpecialDir(dir string) bool { - for { - _, action, ok := strings.Cut(dir, "{{") - if !ok { - return false - } - action, dir, ok = strings.Cut(action, "}}") - if !ok { - return false - } - // Only inspect template actions, not literal path components. - for _, d := range knownAbsDirs { - if strings.Contains(action, d) { - return true - } - } - } + return specialDirRE.MatchString(dir) } // TryAbsToRel tries to convert an absolute path to relative based on the diff --git a/internal/filepathext/filepathext_test.go b/internal/filepathext/filepathext_test.go index 9de492fb14..3c3a9719dc 100644 --- a/internal/filepathext/filepathext_test.go +++ b/internal/filepathext/filepathext_test.go @@ -18,6 +18,8 @@ func TestSmartJoinRelativePathContainingSpecialVariableName(t *testing.T) { filepath.Join("project"+variable, "file.txt"), filepath.Join(variable, "file.txt"), filepath.Join("{{.PROJECT}}"+variable, "file.txt"), + filepath.Join("{{.PROJECT}}"+variable+"{{.SUFFIX}}", "file.txt"), + filepath.Join("{{ "+variable, "file.txt"), } { require.False(t, IsAbs(relative), relative) require.Equal(t, filepath.Join(base, relative), SmartJoin(base, relative)) @@ -38,8 +40,25 @@ func TestSmartJoinAbsoluteAndTemplatePaths(t *testing.T) { "{{- .USER_WORKING_DIR -}}/file.txt", "{{.ROOT_DIR | toSlash}}/file.txt", "{{.PROJECT}}/{{.ROOT_DIR}}/file.txt", + "{{\n.TASKFILE_DIR\n}}/file.txt", + `{{joinPath .ROOT_DIR "src"}}/file.txt`, } { require.True(t, IsAbs(path), path) require.Equal(t, path, SmartJoin(base, path)) } } + +func TestSmartJoinUnrelatedTemplatePaths(t *testing.T) { + t.Parallel() + + base := t.TempDir() + for _, path := range []string{ + "{{.PROJECT}}/file.txt", + "{{XROOT_DIR}}/file.txt", + "{{XTASKFILE_DIR}}/file.txt", + "{{XUSER_WORKING_DIR}}/file.txt", + } { + require.False(t, IsAbs(path), path) + require.Equal(t, filepath.Join(base, path), SmartJoin(base, path)) + } +} From 0623c1ca474b28f0794aab91c1dee58ac5e72f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 8 Sep 2026 17:52:50 +0800 Subject: [PATCH 3/4] fix: reject special directory variable name suffixes --- internal/filepathext/filepathext.go | 5 +++-- internal/filepathext/filepathext_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/filepathext/filepathext.go b/internal/filepathext/filepathext.go index 23356fc193..615969bf22 100644 --- a/internal/filepathext/filepathext.go +++ b/internal/filepathext/filepathext.go @@ -25,8 +25,9 @@ func IsAbs(path string) bool { return filepath.IsAbs(path) } -// Match special directory variables only within a template action. -var specialDirRE = regexp.MustCompile(`\{\{[^{}]*\.(?:ROOT_DIR|TASKFILE_DIR|USER_WORKING_DIR)[^{}]*\}\}`) +// Heuristically match special directory names within template actions, without +// matching prefixes of longer identifiers. This does not parse template syntax. +var specialDirRE = regexp.MustCompile(`\{\{[^{}]*\.(?:ROOT_DIR|TASKFILE_DIR|USER_WORKING_DIR)(?:[^\p{L}\p{Nd}_{}][^{}]*)?\}\}`) func isSpecialDir(dir string) bool { return specialDirRE.MatchString(dir) diff --git a/internal/filepathext/filepathext_test.go b/internal/filepathext/filepathext_test.go index 3c3a9719dc..cf4f014a80 100644 --- a/internal/filepathext/filepathext_test.go +++ b/internal/filepathext/filepathext_test.go @@ -39,6 +39,8 @@ func TestSmartJoinAbsoluteAndTemplatePaths(t *testing.T) { "{{ .TASKFILE_DIR }}/file.txt", "{{- .USER_WORKING_DIR -}}/file.txt", "{{.ROOT_DIR | toSlash}}/file.txt", + "{{.ROOT_DIR|toSlash}}/file.txt", + "{{(.ROOT_DIR)}}/file.txt", "{{.PROJECT}}/{{.ROOT_DIR}}/file.txt", "{{\n.TASKFILE_DIR\n}}/file.txt", `{{joinPath .ROOT_DIR "src"}}/file.txt`, @@ -62,3 +64,20 @@ func TestSmartJoinUnrelatedTemplatePaths(t *testing.T) { require.Equal(t, filepath.Join(base, path), SmartJoin(base, path)) } } + +func TestSmartJoinTemplateVariableNameSuffixes(t *testing.T) { + t.Parallel() + + base := t.TempDir() + for _, variable := range []string{".ROOT_DIR", ".TASKFILE_DIR", ".USER_WORKING_DIR"} { + for _, suffix := range []string{"_SUFFIX", "_EXTRA", "2", "suffix", "变量"} { + t.Run(variable+suffix, func(t *testing.T) { + t.Parallel() + + path := "{{" + variable + suffix + "}}/file.txt" + require.False(t, IsAbs(path), path) + require.Equal(t, filepath.Join(base, path), SmartJoin(base, path)) + }) + } + } +} From 55fa99bf17f1d390995077d6d6ea6a3f7e9f35f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=83=A7=E6=B4=97=E5=8F=91=E7=94=A8=E9=A3=98?= =?UTF-8?q?=E6=9F=94?= <2906266135@qq.com> Date: Tue, 3 Nov 2026 20:52:18 +0800 Subject: [PATCH 4/4] refactor: simplify special directory matching and tests Match complete template actions separately from special directory names, keeping Unicode identifier boundaries and incomplete-template handling. Consolidate path classification coverage into one IsAbs table and remove the redundant taskfile integration test requested during review. --- internal/filepathext/filepathext.go | 11 ++- internal/filepathext/filepathext_test.go | 100 ++++++++--------------- taskfile/node_file_test.go | 33 -------- 3 files changed, 39 insertions(+), 105 deletions(-) delete mode 100644 taskfile/node_file_test.go diff --git a/internal/filepathext/filepathext.go b/internal/filepathext/filepathext.go index 615969bf22..9154a55868 100644 --- a/internal/filepathext/filepathext.go +++ b/internal/filepathext/filepathext.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "regexp" + "slices" ) // SmartJoin joins two paths, but only if the second is not already an @@ -25,12 +26,14 @@ func IsAbs(path string) bool { return filepath.IsAbs(path) } -// Heuristically match special directory names within template actions, without -// matching prefixes of longer identifiers. This does not parse template syntax. -var specialDirRE = regexp.MustCompile(`\{\{[^{}]*\.(?:ROOT_DIR|TASKFILE_DIR|USER_WORKING_DIR)(?:[^\p{L}\p{Nd}_{}][^{}]*)?\}\}`) +var ( + templateActionRE = regexp.MustCompile(`\{\{[^{}]*\}\}`) + // Use a Unicode-aware boundary so longer variable names remain relative. + specialDirRE = regexp.MustCompile(`\.(ROOT_DIR|TASKFILE_DIR|USER_WORKING_DIR)([^\pL\p{Nd}_]|$)`) +) func isSpecialDir(dir string) bool { - return specialDirRE.MatchString(dir) + return slices.ContainsFunc(templateActionRE.FindAllString(dir, -1), specialDirRE.MatchString) } // TryAbsToRel tries to convert an absolute path to relative based on the diff --git a/internal/filepathext/filepathext_test.go b/internal/filepathext/filepathext_test.go index cf4f014a80..e3bbd14b1b 100644 --- a/internal/filepathext/filepathext_test.go +++ b/internal/filepathext/filepathext_test.go @@ -7,77 +7,41 @@ import ( "github.com/stretchr/testify/require" ) -func TestSmartJoinRelativePathContainingSpecialVariableName(t *testing.T) { +func TestIsAbs(t *testing.T) { t.Parallel() - base := t.TempDir() - for _, variable := range []string{".ROOT_DIR", ".TASKFILE_DIR", ".USER_WORKING_DIR"} { - t.Run(variable, func(t *testing.T) { - t.Parallel() - for _, relative := range []string{ - filepath.Join("project"+variable, "file.txt"), - filepath.Join(variable, "file.txt"), - filepath.Join("{{.PROJECT}}"+variable, "file.txt"), - filepath.Join("{{.PROJECT}}"+variable+"{{.SUFFIX}}", "file.txt"), - filepath.Join("{{ "+variable, "file.txt"), - } { - require.False(t, IsAbs(relative), relative) - require.Equal(t, filepath.Join(base, relative), SmartJoin(base, relative)) - } - }) - } -} - -func TestSmartJoinAbsoluteAndTemplatePaths(t *testing.T) { - t.Parallel() - - base := t.TempDir() - absolute := filepath.Join(t.TempDir(), "file.txt") - for _, path := range []string{ - absolute, - "{{.ROOT_DIR}}/file.txt", - "{{ .TASKFILE_DIR }}/file.txt", - "{{- .USER_WORKING_DIR -}}/file.txt", - "{{.ROOT_DIR | toSlash}}/file.txt", - "{{.ROOT_DIR|toSlash}}/file.txt", - "{{(.ROOT_DIR)}}/file.txt", - "{{.PROJECT}}/{{.ROOT_DIR}}/file.txt", - "{{\n.TASKFILE_DIR\n}}/file.txt", - `{{joinPath .ROOT_DIR "src"}}/file.txt`, - } { - require.True(t, IsAbs(path), path) - require.Equal(t, path, SmartJoin(base, path)) - } -} - -func TestSmartJoinUnrelatedTemplatePaths(t *testing.T) { - t.Parallel() - - base := t.TempDir() - for _, path := range []string{ - "{{.PROJECT}}/file.txt", - "{{XROOT_DIR}}/file.txt", - "{{XTASKFILE_DIR}}/file.txt", - "{{XUSER_WORKING_DIR}}/file.txt", + absolute, err := filepath.Abs("file.txt") + require.NoError(t, err) + + for _, test := range []struct { + path string + wantAbs bool + }{ + {"", false}, + {"project.ROOT_DIR/file.txt", false}, + {".TASKFILE_DIR/file.txt", false}, + {"project.USER_WORKING_DIR/file.txt", false}, + {"{{.PROJECT}}.ROOT_DIR{{.SUFFIX}}/file.txt", false}, + {"{{ .ROOT_DIR/file.txt", false}, + {"{{.PROJECT}}/file.txt", false}, + {"{{.ROOT_DIR_SUFFIX}}/file.txt", false}, + {"{{.ROOT_DIR2}}/file.txt", false}, + {"{{.ROOT_DIRsuffix}}/file.txt", false}, + {"{{.ROOT_DIR变量}}/file.txt", false}, + {absolute, true}, + {"{{.ROOT_DIR}}/file.txt", true}, + {"{{ .TASKFILE_DIR }}/file.txt", true}, + {"{{- .USER_WORKING_DIR -}}/file.txt", true}, + {"{{.ROOT_DIR|toSlash}}/file.txt", true}, + {"{{(.ROOT_DIR)}}/file.txt", true}, + {"{{.PROJECT}}/{{.ROOT_DIR}}/file.txt", true}, + {"{{\n.TASKFILE_DIR\n}}/file.txt", true}, + {`{{joinPath .ROOT_DIR "src"}}/file.txt`, true}, } { - require.False(t, IsAbs(path), path) - require.Equal(t, filepath.Join(base, path), SmartJoin(base, path)) - } -} - -func TestSmartJoinTemplateVariableNameSuffixes(t *testing.T) { - t.Parallel() - - base := t.TempDir() - for _, variable := range []string{".ROOT_DIR", ".TASKFILE_DIR", ".USER_WORKING_DIR"} { - for _, suffix := range []string{"_SUFFIX", "_EXTRA", "2", "suffix", "变量"} { - t.Run(variable+suffix, func(t *testing.T) { - t.Parallel() + t.Run(test.path, func(t *testing.T) { + t.Parallel() - path := "{{" + variable + suffix + "}}/file.txt" - require.False(t, IsAbs(path), path) - require.Equal(t, filepath.Join(base, path), SmartJoin(base, path)) - }) - } + require.Equal(t, test.wantAbs, IsAbs(test.path)) + }) } } diff --git a/taskfile/node_file_test.go b/taskfile/node_file_test.go deleted file mode 100644 index 8f8552d550..0000000000 --- a/taskfile/node_file_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package taskfile - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFileNodeResolveLiteralSpecialDir(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - entrypoint := filepath.Join(dir, "Taskfile.yml") - require.NoError(t, os.WriteFile(entrypoint, []byte("version: '3'\n"), 0o600)) - node, err := NewFileNode(entrypoint, "") - require.NoError(t, err) - - for _, name := range []string{"project.ROOT_DIR", "project.TASKFILE_DIR", "project.USER_WORKING_DIR"} { - t.Run(name, func(t *testing.T) { - t.Parallel() - - resolvedDir, err := node.ResolveDir(name) - require.NoError(t, err) - require.Equal(t, filepath.Join(dir, name), resolvedDir) - - resolvedEntrypoint, err := node.ResolveEntrypoint(filepath.Join(name, "Taskfile.yml")) - require.NoError(t, err) - require.Equal(t, filepath.Join(dir, name, "Taskfile.yml"), resolvedEntrypoint) - }) - } -}