Skip to content

Commit d3c0ca6

Browse files
authored
Merge v0.66.3 to master (#6190)
2 parents 4faea0e + 5a8adef commit d3c0ca6

7 files changed

Lines changed: 447 additions & 19 deletions

File tree

‎pkg/commands/git_commands/submodule.go‎

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package git_commands
22

33
import (
44
"bufio"
5+
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
79
"regexp"
@@ -46,7 +48,36 @@ func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig)
4648
}
4749
defer file.Close()
4850

49-
scanner := bufio.NewScanner(file)
51+
parsedConfigs, err := parseGitmodules(file, parentModule)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
configs := []*models.SubmoduleConfig{}
57+
for _, config := range parsedConfigs {
58+
if isSuspiciousSubmoduleName(config.Name) {
59+
self.Log.Warnf("Ignoring suspicious submodule name: %s", config.Name)
60+
continue
61+
}
62+
63+
if isSuspiciousSubmodulePath(dir, config.Path) {
64+
self.Log.Warnf("Ignoring suspicious submodule path: %s", config.Path)
65+
continue
66+
}
67+
68+
configs = append(configs, config)
69+
70+
nestedConfigs, err := self.GetConfigs(config)
71+
if err == nil {
72+
configs = append(configs, nestedConfigs...)
73+
}
74+
}
75+
76+
return configs, nil
77+
}
78+
79+
func parseGitmodules(reader io.Reader, parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) {
80+
scanner := bufio.NewScanner(reader)
5081
scanner.Split(bufio.ScanLines)
5182

5283
firstMatch := func(str string, regex string) (string, bool) {
@@ -60,27 +91,22 @@ func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig)
6091
}
6192

6293
configs := []*models.SubmoduleConfig{}
63-
lastConfigIdx := -1
6494
for scanner.Scan() {
6595
line := scanner.Text()
6696

67-
if name, ok := firstMatch(line, `\[submodule "(.*)"\]`); ok {
97+
if name, ok := firstMatch(line, `^\s*\[submodule "(.*)"\]`); ok {
6898
configs = append(configs, &models.SubmoduleConfig{
6999
Name: name, ParentModule: parentModule,
70100
})
71-
lastConfigIdx = len(configs) - 1
72101
continue
73102
}
74103

75-
if lastConfigIdx != -1 {
76-
if path, ok := firstMatch(line, `\s*path\s*=\s*(.*)\s*`); ok {
77-
configs[lastConfigIdx].Path = path
78-
nestedConfigs, err := self.GetConfigs(configs[lastConfigIdx])
79-
if err == nil {
80-
configs = append(configs, nestedConfigs...)
81-
}
82-
} else if url, ok := firstMatch(line, `\s*url\s*=\s*(.*)\s*`); ok {
83-
configs[lastConfigIdx].Url = url
104+
if len(configs) > 0 {
105+
config := configs[len(configs)-1]
106+
if path, ok := firstMatch(line, `^\s*path\s*=\s*(.*)\s*`); ok {
107+
config.Path = path
108+
} else if url, ok := firstMatch(line, `^\s*url\s*=\s*(.*)\s*`); ok {
109+
config.Url = url
84110
}
85111
}
86112
}
@@ -92,6 +118,52 @@ func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig)
92118
return configs, nil
93119
}
94120

121+
// isSuspiciousSubmoduleName applies the same rule as git's
122+
// check_submodule_name(); git ignores a submodule whose name fails it. The
123+
// git dir of a submodule is .git/modules/<name>, so a ".." component in the
124+
// name could put it outside the repository. Like git, we treat both slashes
125+
// and backslashes as separators on every platform.
126+
func isSuspiciousSubmoduleName(name string) bool {
127+
if name == "" {
128+
return true
129+
}
130+
131+
components := strings.FieldsFunc(name, func(r rune) bool {
132+
return r == '/' || r == '\\'
133+
})
134+
return lo.Contains(components, "..")
135+
}
136+
137+
// isSuspiciousSubmodulePath reports whether a submodule path from the
138+
// .gitmodules file in parentDir might lead somewhere other than a directory
139+
// below parentDir. We run commands in the submodule's directory and read the
140+
// .gitmodules file in it, so the path must not lead anywhere else.
141+
//
142+
// Git never checks out a file through a symlink, so a path that goes through
143+
// a symlink can't be a submodule in the first place. We reject such paths
144+
// even if the symlink points to a directory below parentDir.
145+
func isSuspiciousSubmodulePath(parentDir string, path string) bool {
146+
if !filepath.IsLocal(path) || filepath.Clean(path) == "." {
147+
return true
148+
}
149+
150+
dir := parentDir
151+
for _, component := range strings.Split(filepath.Clean(path), string(filepath.Separator)) {
152+
dir = filepath.Join(dir, component)
153+
fileInfo, err := os.Lstat(dir)
154+
if os.IsNotExist(err) {
155+
// Nothing below this point exists (e.g. because the submodule
156+
// isn't checked out), so it can't lead anywhere else either
157+
return false
158+
}
159+
if err != nil || !fileInfo.IsDir() {
160+
return true
161+
}
162+
}
163+
164+
return false
165+
}
166+
95167
// AnyHaveStageableChanges reports whether any of the given submodule paths has
96168
// a checked-out commit that differs from the one recorded in the
97169
// superproject's index, i.e. a change that `git add <path>` would actually
@@ -237,6 +309,18 @@ func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConf
237309
func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
238310
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
239311

312+
// Git keeps the git dirs of submodules separately for each worktree
313+
gitDirPath := submodule.GitDirPath(self.repoPaths.WorktreeGitDirPath())
314+
315+
// The name of the submodule comes from .gitmodules, so it is under the
316+
// control of whoever committed that file. Make sure that we never delete
317+
// anything other than a directory below the "modules" directory of the
318+
// worktree's git dir.
319+
modulesDirPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "modules")
320+
if relPath, err := filepath.Rel(modulesDirPath, gitDirPath); err != nil || relPath == "." || !filepath.IsLocal(relPath) {
321+
return fmt.Errorf("refusing to delete %s because it is not inside %s", gitDirPath, modulesDirPath)
322+
}
323+
240324
if err := self.runInParentModule(submodule, self.cmd.New(
241325
NewGitCmd("submodule").
242326
Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
@@ -269,9 +353,7 @@ func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
269353
self.Log.Error(err)
270354
}
271355

272-
// We may in fact want to use the repo's git dir path but git docs say not to
273-
// mix submodules and worktrees anyway.
274-
return os.RemoveAll(submodule.GitDirPath(self.repoPaths.repoGitDirPath))
356+
return os.RemoveAll(gitDirPath)
275357
}
276358

277359
func (self *SubmoduleCommands) Add(name string, path string, url string) error {

‎pkg/commands/git_commands/submodule_test.go‎

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,210 @@
11
package git_commands
22

33
import (
4+
"os"
5+
"path/filepath"
46
"strings"
57
"testing"
68

79
"github.com/go-errors/errors"
10+
"github.com/jesseduffield/lazygit/pkg/commands/models"
811
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
912
"github.com/jesseduffield/lazygit/pkg/env"
1013
"github.com/samber/lo"
1114
"github.com/stretchr/testify/assert"
1215
)
1316

17+
func TestParseGitmodules(t *testing.T) {
18+
scenarios := []struct {
19+
testName string
20+
lines []string
21+
expected []*models.SubmoduleConfig
22+
}{
23+
{
24+
testName: "two submodules",
25+
lines: []string{
26+
`[submodule "one"]`,
27+
"\tpath = path/to/one",
28+
"\turl = https://example.com/one.git",
29+
`[submodule "two"]`,
30+
"\tpath = two",
31+
"\turl = ../two",
32+
},
33+
expected: []*models.SubmoduleConfig{
34+
{Name: "one", Path: "path/to/one", Url: "https://example.com/one.git"},
35+
{Name: "two", Path: "two", Url: "../two"},
36+
},
37+
},
38+
{
39+
testName: "url that contains path=",
40+
lines: []string{
41+
`[submodule "sub"]`,
42+
"\tpath = sub",
43+
"\turl = https://example.com/repo?path=other",
44+
},
45+
expected: []*models.SubmoduleConfig{
46+
{Name: "sub", Path: "sub", Url: "https://example.com/repo?path=other"},
47+
},
48+
},
49+
{
50+
testName: "commented-out section",
51+
lines: []string{
52+
`[submodule "sub"]`,
53+
"\tpath = sub",
54+
"\turl = ../sub",
55+
`#[submodule "old"]`,
56+
"#\tpath = old",
57+
"#\turl = ../old",
58+
},
59+
expected: []*models.SubmoduleConfig{
60+
{Name: "sub", Path: "sub", Url: "../sub"},
61+
},
62+
},
63+
}
64+
65+
for _, s := range scenarios {
66+
t.Run(s.testName, func(t *testing.T) {
67+
configs, err := parseGitmodules(strings.NewReader(strings.Join(s.lines, "\n")), nil)
68+
assert.NoError(t, err)
69+
assert.Equal(t, s.expected, configs)
70+
})
71+
}
72+
}
73+
74+
func TestIsSuspiciousSubmoduleName(t *testing.T) {
75+
scenarios := []struct {
76+
name string
77+
suspicious bool
78+
}{
79+
{name: "sub", suspicious: false},
80+
{name: "path/to/sub", suspicious: false},
81+
{name: "path\\to\\sub", suspicious: false},
82+
{name: ".", suspicious: false},
83+
{name: "...", suspicious: false},
84+
{name: "..sub", suspicious: false},
85+
{name: "sub..", suspicious: false},
86+
{name: "a..b/c", suspicious: false},
87+
{name: "", suspicious: true},
88+
{name: "..", suspicious: true},
89+
{name: "../victim", suspicious: true},
90+
{name: "../../../victim", suspicious: true},
91+
{name: "sub/..", suspicious: true},
92+
{name: "sub/../../victim", suspicious: true},
93+
{name: "..\\victim", suspicious: true},
94+
{name: "sub\\..\\..\\victim", suspicious: true},
95+
{name: "sub/..\\..\\victim", suspicious: true},
96+
}
97+
98+
for _, s := range scenarios {
99+
t.Run(s.name, func(t *testing.T) {
100+
assert.Equal(t, s.suspicious, isSuspiciousSubmoduleName(s.name))
101+
})
102+
}
103+
}
104+
105+
func TestIsSuspiciousSubmodulePath(t *testing.T) {
106+
dir := t.TempDir()
107+
parentDir := filepath.Join(dir, "repo")
108+
assert.NoError(t, os.MkdirAll(filepath.Join(parentDir, "sub"), 0o755))
109+
assert.NoError(t, os.MkdirAll(filepath.Join(parentDir, "path", "to", "sub"), 0o755))
110+
assert.NoError(t, os.MkdirAll(filepath.Join(dir, "other"), 0o755))
111+
assert.NoError(t, os.WriteFile(filepath.Join(parentDir, "file"), []byte("content"), 0o644))
112+
113+
scenarios := []struct {
114+
path string
115+
suspicious bool
116+
}{
117+
{path: "sub", suspicious: false},
118+
{path: "sub/", suspicious: false},
119+
{path: "path/to/sub", suspicious: false},
120+
{path: "not_checked_out", suspicious: false},
121+
{path: "path/not_checked_out/sub", suspicious: false},
122+
{path: "", suspicious: true},
123+
{path: ".", suspicious: true},
124+
{path: "./", suspicious: true},
125+
{path: "sub/..", suspicious: true},
126+
{path: "..", suspicious: true},
127+
{path: "../other", suspicious: true},
128+
{path: "sub/../../other", suspicious: true},
129+
{path: filepath.Join(dir, "other"), suspicious: true},
130+
{path: "file", suspicious: true},
131+
{path: "file/sub", suspicious: true},
132+
}
133+
134+
for _, s := range scenarios {
135+
t.Run(s.path, func(t *testing.T) {
136+
assert.Equal(t, s.suspicious, isSuspiciousSubmodulePath(parentDir, s.path))
137+
})
138+
}
139+
}
140+
141+
func TestIsSuspiciousSubmodulePathWithSymlinks(t *testing.T) {
142+
dir := t.TempDir()
143+
parentDir := filepath.Join(dir, "repo")
144+
assert.NoError(t, os.MkdirAll(filepath.Join(parentDir, "sub"), 0o755))
145+
assert.NoError(t, os.MkdirAll(filepath.Join(dir, "other", "sub"), 0o755))
146+
if err := os.Symlink(filepath.Join("..", "other"), filepath.Join(parentDir, "link_outside")); err != nil {
147+
t.Skipf("Can't create symlinks: %v", err)
148+
}
149+
assert.NoError(t, os.Symlink("sub", filepath.Join(parentDir, "link_inside")))
150+
151+
scenarios := []struct {
152+
path string
153+
suspicious bool
154+
}{
155+
{path: "sub", suspicious: false},
156+
{path: "link_outside", suspicious: true},
157+
{path: "link_outside/sub", suspicious: true},
158+
{path: "link_inside", suspicious: true},
159+
}
160+
161+
for _, s := range scenarios {
162+
t.Run(s.path, func(t *testing.T) {
163+
assert.Equal(t, s.suspicious, isSuspiciousSubmodulePath(parentDir, s.path))
164+
})
165+
}
166+
}
167+
168+
func TestSubmoduleDeleteOutsideModulesDir(t *testing.T) {
169+
scenarios := []struct {
170+
testName string
171+
name string
172+
// relative to the directory that contains the repo
173+
victimPath string
174+
}{
175+
{
176+
testName: "modules dir itself",
177+
name: ".",
178+
victimPath: "repo/.git/modules/other_submodule",
179+
},
180+
{
181+
testName: "outside the repo",
182+
name: "../../../victim",
183+
victimPath: "victim",
184+
},
185+
}
186+
187+
for _, s := range scenarios {
188+
t.Run(s.testName, func(t *testing.T) {
189+
dir := t.TempDir()
190+
victimFile := filepath.Join(dir, s.victimPath, "file")
191+
assert.NoError(t, os.MkdirAll(filepath.Dir(victimFile), 0o755))
192+
assert.NoError(t, os.WriteFile(victimFile, []byte("content"), 0o644))
193+
194+
runner := oscommands.NewFakeRunner(t)
195+
instance := buildSubmoduleCommands(commonDeps{
196+
runner: runner,
197+
repoPaths: MockRepoPaths(filepath.Join(dir, "repo")),
198+
})
199+
200+
err := instance.Delete(&models.SubmoduleConfig{Name: s.name, Path: "sub"})
201+
assert.Error(t, err)
202+
assert.FileExists(t, victimFile)
203+
runner.CheckForMissingCalls()
204+
})
205+
}
206+
}
207+
14208
func TestSubmoduleGetConflictCommits(t *testing.T) {
15209
type scenario struct {
16210
testName string

‎pkg/commands/models/submodule_config.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ func (r *SubmoduleConfig) Description() string {
3838
return r.RefName()
3939
}
4040

41-
func (r *SubmoduleConfig) GitDirPath(repoGitDirPath string) string {
42-
parentPath := repoGitDirPath
41+
func (r *SubmoduleConfig) GitDirPath(worktreeGitDirPath string) string {
42+
parentPath := worktreeGitDirPath
4343
if r.ParentModule != nil {
44-
parentPath = r.ParentModule.GitDirPath(repoGitDirPath)
44+
parentPath = r.ParentModule.GitDirPath(worktreeGitDirPath)
4545
}
4646

4747
return filepath.Join(parentPath, "modules", r.Name)

0 commit comments

Comments
 (0)