diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 60db924..6fcbf88 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -59,7 +59,9 @@ in [GIT-BEHAVIOUR.md](GIT-BEHAVIOUR.md). | Relative `gitdir:./` from the command line | prints an error, goes on without the include | `ErrNotAbsolute` | no one to warn — *safe default* | | Relative `include.path` from the command line | error | `ErrNotAbsolute` | 1 | | Windows: `GIT_DIR` written with `\`, pattern with `/`, through a symbolic link | the path as given does not match | matches | looks like a git for Windows bug | -| `hasconfig:remote.*.url:` | supported | treated as false | not implemented yet (S8): the effective configuration can differ from git's | +| `hasconfig:remote.*.url:` | supported | the same | 1 | +| `hasconfig:remote.*.url:` met, remote URL in a file included by a `gitdir:` or `onbranch:` condition | fatal error, as for one included by `hasconfig:` itself | the same | 1, though it looks like a git bug | +| `hasconfig:remote.*.url:` met, implicit `remote..url` (no `=`) | segmentation fault | not a URL: matches nothing | git gives no answer | | `onbranch:` with reftable | supported | treated as false | needs the ref store (S10) | ## Scopes and discovery diff --git a/docs/GIT-BEHAVIOUR.md b/docs/GIT-BEHAVIOUR.md index 2ed8165..25efc93 100644 --- a/docs/GIT-BEHAVIOUR.md +++ b/docs/GIT-BEHAVIOUR.md @@ -241,6 +241,28 @@ the same results. | `git -c 'includeIf.gitdir:./.path=…'` | `error: relative config include conditionals must come from files`, then git goes on without the include, exit 0 *(2.47.3)* — this library fails with `ErrNotAbsolute`, having no one to warn | | `gitdir:C:/x/link/`, `GIT_DIR=C:\x\link\proj\.git` with `link` a symbolic link to `work` (Windows) | **no**, while `gitdir:C:/x/work/` (the real path) matches *(2.55.0.windows.5)* — this library compares both paths with `/` and matches | +### `hasconfig:remote.*.url:` (remote `origin` at `https://example.com/a/b`) + +Observed with git 2.43.0 and 2.56.0 (`alpine:edge`), with the same results. + +| Condition or setup | Included? | +|---|---| +| `https://example.com/**`, `https://example.com/a/*`, `**/b`, `**`, `https://***/b`, `https://example.com/a/[a-c]` | yes | +| `https://example.com/*`, `https://example.com/` | no: `*` stops at `/`, a trailing `/` adds nothing | +| `https://EXAMPLE.com/**`, `example.com/**`, `*`, empty | no: case-sensitive, no implicit `**/` prefix | +| `hasconfig:remote.origin.url:**`, `hasconfig:remote.*.pushurl:**`, `hasconfig:user.name:**`, `HASCONFIG:remote.*.url:**`, `hasconfig:REMOTE.*.URL:**` | no: only this exact prefix is a condition | +| URL in the local file, condition in the global file | yes | +| URL from `-c`, `GIT_CONFIG_COUNT` | yes | +| URL further down the same file, or in a plain include | yes: every URL counts, read before or after | +| URL `[remote ""]` (empty name), `[Remote "o"] URL` | yes | +| `[remote] url` (no name), only a `pushurl` | no | +| `git config --global --includes`, `--local --includes`, `-f --includes` | only the URLs of that scope or file count | +| URL in a file included by the condition, matching or not, or below it through `include.path` | `fatal: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url`, exit 128 | +| URL in a file included by a `gitdir:` or `onbranch:` condition that holds, with any `hasconfig:` condition met, even one with a missing file | the same fatal error; without a `hasconfig:` condition, or with the `gitdir:` condition false, it is read | +| `hasconfig:` condition only inside an include that is not followed | not met: no error | +| syntax error in the file of a `hasconfig:` condition that does not match | `fatal: bad config line 1`: the file is read | +| implicit `[remote "o"] url` (no `=`) with a `hasconfig:` condition met | segmentation fault (2.43.0 and 2.56.0) — this library takes it as no URL | + ## Ownership and `safe.directory` Observed with **git 2.56.0** (`alpine:edge`, Linux), as user `alice` (uid diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index af5f9d9..a94b98c 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -50,7 +50,7 @@ from git, and the principles that decide it, are in | S7 | `includeIf` conditions `gitdir:`, `gitdir/i:` (matched against the git directory as given and its real path, as git does; the git directory must be absolute; glob matching where `*` and `?` stop at `/` and `**/` spans directories, implicit `**/` prefix for relative patterns and `/**` suffix for a trailing `/`), `onbranch:`. The current branch is read from `HEAD` when needed. *verified* | MUST | | S10 | `onbranch:` for repositories whose `HEAD` is not a plain file (reftable, in-memory storage), with a branch supplied by the caller. | LATER | | S11 | Reading one scope whose file is absent (no system file, missing global or worktree file) gives an empty configuration, as `git config --get` does (`--list` fails there). Reading the local or worktree scope outside a repository fails, as in git. *verified* | MUST | -| S8 | `includeIf "hasconfig:remote.*.url:…"`, including git's rule that files it includes may not define remote URLs. | SHOULD | +| S8 | `includeIf "hasconfig:remote.*.url:…"`, including git's rule that files it includes may not define remote URLs. *verified* | SHOULD | | S9 | Protected configuration: some keys (e.g. `safe.directory`) only honoured from system, global and command scopes. See X5. | MUST | ## 4. Writes and concurrency diff --git a/env.go b/env.go index ea4b89e..fe584a2 100644 --- a/env.go +++ b/env.go @@ -151,14 +151,22 @@ func Discover(dir string) (Env, error) { // which scope it comes from. Missing files are skipped. // // Includes are followed as git does (include.path and includeIf with the -// gitdir:, gitdir/i: and onbranch: conditions; hasconfig: is never -// true), up to 10 levels deep (ErrIncludeDepth). Two safety bounds git lacks -// are added: an include must be a regular file (ErrNotRegular, so a FIFO or a -// device cannot hang or exhaust the process), and one load reads at most -// 1000 files, 4 MiB and 2¹⁶ entries and distinct section headers in total -// from files (ErrLoadLimit, so that a file including itself many times cannot -// multiply the work exponentially, and a file of tiny entries cannot exhaust -// memory). +// gitdir:, gitdir/i:, onbranch: and hasconfig:remote.*.url: conditions), +// up to 10 levels deep (ErrIncludeDepth). hasconfig:remote.*.url: matches +// the remote URLs of every scope the method reads (only that scope for +// LoadScope, that file for LoadFile); as in git, once such a condition is +// met, by any key of its section, a remote URL in a file included by any +// includeIf is an error (ErrRemoteURLInInclude), and the files of these +// conditions are read even when they do not match. +// +// Two safety bounds git lacks are added: an include must be a regular file +// (ErrNotRegular, so a FIFO or a device cannot hang or exhaust the +// process), and one load reads at most 1000 files, 4 MiB and 2¹⁶ entries +// and distinct section headers in total from files (ErrLoadLimit, so that a +// file including itself many times cannot multiply the work exponentially, +// and a file of tiny entries cannot exhaust memory). The files of +// hasconfig:remote.*.url: conditions count against these limits even when +// they do not match: they are read all the same. // Real configurations never come near these bounds. // // As in git, includes are followed as a file is read: an error in an diff --git a/env_test.go b/env_test.go index b38e633..8bfe6c3 100644 --- a/env_test.go +++ b/env_test.go @@ -759,6 +759,138 @@ func TestEnvIncludeIf(t *testing.T) { assert.False(t, included(t, env, "gitdir")) }) + t.Run("hasconfig remote URL", func(t *testing.T) { + // Checked against git 2.43 and 2.56: plain glob matching against + // each remote..url, of any scope, read before or after. + gitDir := filepath.Join(tempDir(t), ".git") + initRepo(t, gitDir, "[remote \"origin\"]\n\turl = https://example.com/a/b\n") + env := gitconfig.Env{GitDir: gitDir} + for _, tc := range []struct { + cond string + want bool + }{ + {"hasconfig:remote.*.url:https://example.com/**", true}, + {"hasconfig:remote.*.url:https://example.com/a/*", true}, + {"hasconfig:remote.*.url:https://example.com/a/[a-c]", true}, + {"hasconfig:remote.*.url:**/b", true}, + {"hasconfig:remote.*.url:**", true}, + {"hasconfig:remote.*.url:https://example.com/*", false}, + {"hasconfig:remote.*.url:https://example.com/", false}, + {"hasconfig:remote.*.url:https://EXAMPLE.com/**", false}, + {"hasconfig:remote.*.url:example.com/**", false}, + {"hasconfig:remote.*.url:*", false}, + {"hasconfig:remote.*.url:", false}, + {"hasconfig:remote.origin.url:**", false}, + {"hasconfig:remote.*.pushurl:**", false}, + {"hasconfig:REMOTE.*.URL:**", false}, + {"HASCONFIG:remote.*.url:**", false}, + } { + assert.Equal(t, tc.want, included(t, env, tc.cond), tc.cond) + } + + outside := gitconfig.Env{Home: home} + assert.False(t, included(t, outside, "hasconfig:remote.*.url:**")) + outside.Command = []gitconfig.CommandEntry{{Key: "remote.o.url", Value: "https://x/y"}} + assert.True(t, included(t, outside, "hasconfig:remote.*.url:https://x/**")) + }) + + t.Run("hasconfig remote URL read later", func(t *testing.T) { + global := filepath.Join(tempDir(t), "global") + writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = %s/inc\n[a]\n\tk = after\n[remote \"\"]\n\turl = u\n", s)) + c, err := gitconfig.Env{GlobalConfig: []string{global}}.Load() + require.NoError(t, err) + assert.Equal(t, []string{"inc", "after"}, values(c.GetAll("a.k"))) + }) + + t.Run("hasconfig remote URL of the scope read", func(t *testing.T) { + gitDir := filepath.Join(tempDir(t), ".git") + initRepo(t, gitDir, "[remote \"origin\"]\n\turl = u\n") + global := filepath.Join(tempDir(t), "global") + writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:u\"]\n\tpath = %s/inc\n", s)) + env := gitconfig.Env{GitDir: gitDir, GlobalConfig: []string{global}} + c, err := env.Load() + require.NoError(t, err) + _, ok := c.Get("a.k") + assert.True(t, ok, "Load") + c, err = env.LoadScope(gitconfig.ScopeGlobal) + require.NoError(t, err) + _, ok = c.Get("a.k") + assert.False(t, ok, "LoadScope: the local URL is not read") + c, err = env.LoadFile(global) + require.NoError(t, err) + _, ok = c.Get("a.k") + assert.False(t, ok, "LoadFile") + }) + + t.Run("hasconfig remote URL not matched drops nested includes", func(t *testing.T) { + dir := tempDir(t) + writeFile(t, filepath.Join(dir, "outer"), "[a]\n\tk = outer\n[include]\n\tpath = nested\n") + writeFile(t, filepath.Join(dir, "nested"), "[a]\n\tk = nested\n") + writeFile(t, filepath.Join(dir, "global"), "[a]\n\tk = before\n[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = outer\n[a]\n\tk = after\n") + c, err := gitconfig.Env{GlobalConfig: []string{filepath.Join(dir, "global")}}.Load() + require.NoError(t, err) + assert.Equal(t, []string{"before", "after"}, values(c.GetAll("a.k"))) + }) + + t.Run("implicit remote URL", func(t *testing.T) { + // git 2.43 and 2.56 crash on it: it is no URL here. + global := filepath.Join(tempDir(t), "global") + writeFile(t, global, fmt.Sprintf("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = %s/inc\n[remote \"o\"]\n\turl\n", s)) + c, err := gitconfig.Env{GlobalConfig: []string{global}}.Load() + require.NoError(t, err) + _, ok := c.Get("a.k") + assert.False(t, ok) + }) + + t.Run("remote URL in a file included by includeIf", func(t *testing.T) { + // git fails as soon as a hasconfig:remote.*.url: condition is met, + // even if it does not match: its file is read anyway, as are + // the files of the other conditions that hold. + dir := tempDir(t) + ds := filepath.ToSlash(dir) + gitDir := filepath.Join(dir, "repo", ".git") + initRepo(t, gitDir, "") + writeFile(t, filepath.Join(dir, "url"), "[a]\n\tk = v\n[Remote \"o\"]\n\tURL = https://x/y\n") + writeFile(t, filepath.Join(dir, "include-url"), "[include]\n\tpath = url\n") + writeFile(t, filepath.Join(dir, "bad"), "bad line\n") + for _, tc := range []struct { + name, global string + err string // the file at fault, "" for none + }{ + {"in a hasconfig include", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = url\n", "url"}, + {"in one that does not match", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = url\n", "url"}, + {"below one", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = include-url\n", "url"}, + {"in a gitdir include", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = missing\n[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", "url"}, + {"in a gitdir include, no hasconfig", "[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", ""}, + {"in a gitdir include that does not hold", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = missing\n[includeIf \"gitdir:/nope/\"]\n\tpath = url\n", ""}, + {"in a plain include", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = missing\n[include]\n\tpath = url\n", ""}, + {"syntax error in one that does not match", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = bad\n", "bad"}, + // Any key meets the condition, before or after the URL. + {"condition with no path", "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tfoo = bar\n[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n", "url"}, + {"condition with no path, after", "[includeIf \"gitdir:" + ds + "/\"]\n\tpath = url\n[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tfoo = bar\n", "url"}, + // git fails at the URL, before reading on. + {"syntax error after", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = url\nbad line\n", "url"}, + } { + t.Run(tc.name, func(t *testing.T) { + writeFile(t, filepath.Join(dir, "global"), tc.global) + _, err := gitconfig.Env{GitDir: gitDir, GlobalConfig: []string{filepath.Join(dir, "global")}}.Load() + if tc.err == "" { + require.NoError(t, err) + return + } + fe, ok := errors.AsType[*gitconfig.FileError](err) + require.True(t, ok, "%v", err) + assert.Equal(t, filepath.Join(dir, tc.err), fe.Path) + if tc.err == "url" { + assert.ErrorIs(t, err, gitconfig.ErrRemoteURLInInclude) + } else { + _, ok = errors.AsType[*gitconfig.SyntaxError](err) + assert.True(t, ok, "%v", err) + } + }) + } + }) + t.Run("relative gitdir pattern from the command line", func(t *testing.T) { // git prints an error and goes on without the include. env := gitconfig.Env{GitDir: gitDir, Command: []gitconfig.CommandEntry{{Key: "includeIf.gitdir:./.path", Value: s + "/inc"}}} @@ -2233,3 +2365,79 @@ func BenchmarkEnv(b *testing.B) { } }) } + +// FuzzLoad loads a global and a local file and three files they can +// include, from any bytes: "@0", "@1" and "@2" stand for the paths of the +// included files and "@R" for the directory holding them all, so that +// mutations reach includes and conditions. Every loaded entry must be an +// entry of the file it names, and a failure a *FileError. +func FuzzLoad(f *testing.F) { + root := tempDir(f) + home := filepath.Join(root, "home") + gitDir := filepath.Join(root, "work", ".git") + initRepo(f, gitDir, "") + files := []string{filepath.Join(home, ".gitconfig"), filepath.Join(gitDir, "config"), + filepath.Join(root, "i0"), filepath.Join(root, "i1"), filepath.Join(root, "i2")} + expand := strings.NewReplacer("@0", files[2], "@1", files[3], "@2", files[4], "@R", filepath.ToSlash(root)) + + f.Add("[includeIf \"hasconfig:remote.*.url:https://x/**\"]\n\tpath = @0\n", "[remote \"o\"]\n\turl = https://x/y\n", "[a]\n\tk = v\n", "", "") + f.Add("[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = @0\n", "", "[remote \"o\"]\n\turl = u\n", "", "") + f.Add("[includeIf \"hasconfig:remote.*.url:*\"]\n\tpath = @0\n[includeIf \"gitdir:@R/\"]\n\tpath = @1\n", "[remote \"\"]\n\turl = u\n", "[include]\n\tpath = @2\n", "[remote \"o\"]\n\turl\n", "[a]\n\tk\n") + f.Add("[include]\n\tpath = @0\n", "[includeIf \"onbranch:main\"]\n\tpath = @1\n", "[include]\n\tpath = @0\n", "[includeIf \"gitdir/i:@R/**\"]\n\tpath = @2\n", "bad line\n") + f.Add("[includeIf \"hasconfig:remote.*.url:u\"]\n\tpath = @0\n[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = @1\n", "[remote \"a\"] url = u\n", "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = @2\n", "[x]\n\ty = 1\n", "[z]\n\tw = 2\n") + + f.Fuzz(func(t *testing.T, global, local, i0, i1, i2 string) { + parsed := make(map[string][]gitconfig.Entry) + for i, content := range []string{global, local, i0, i1, i2} { + data := expand.Replace(content) + writeFile(t, files[i], data) + if file, err := gitconfig.Parse(files[i], []byte(data)); err == nil { + parsed[files[i]] = slices.Collect(file.Config().All()) + } + } + env := gitconfig.Env{Home: home, GlobalConfig: files[:1], GitDir: gitDir} + + check := func(name string, c *gitconfig.Config, err error) []gitconfig.Entry { + t.Helper() + if err != nil { + fe, ok := errors.AsType[*gitconfig.FileError](err) + require.True(t, ok, "%s: %T %v", name, err, err) + // Any path an include names, such as a directory. + require.NotEmpty(t, fe.Path, name) + return nil + } + var entries []gitconfig.Entry + for e := range c.All() { + // Origins are not cleaned, as in git: "/@1" is "///i1". + own, ok := parsed[filepath.Clean(e.Origin.File)] + if !ok { + // An include outside the test files, such as /etc/gitconfig. + if data, err := os.ReadFile(e.Origin.File); err == nil { + if file, err := gitconfig.Parse(e.Origin.File, data); err == nil { + own, ok = slices.Collect(file.Config().All()), true + parsed[filepath.Clean(e.Origin.File)] = own + } + } + } + require.True(t, ok, "%s: entry from %q, unparsable or unknown", name, e.Origin.File) + require.True(t, slices.ContainsFunc(own, func(p gitconfig.Entry) bool { + return p.Key == e.Key && p.Value == e.Value && p.Implicit == e.Implicit && p.Origin.Line == e.Origin.Line + }), "%s: %v is not in its file", name, e) + entries = append(entries, e) + } + return entries + } + c, err := env.Load() + first := check("Load", c, err) + c, err = env.Load() + require.Equal(t, first, check("Load again", c, err)) + for _, s := range []gitconfig.Scope{gitconfig.ScopeGlobal, gitconfig.ScopeLocal} { + c, err := env.LoadScope(s) + check("LoadScope "+s.String(), c, err) + } + c, err = env.LoadProtected() + check("LoadProtected", c, err) + c, err = env.LoadFile(files[2]) + check("LoadFile", c, err) + }) +} diff --git a/errors.go b/errors.go index 660ce71..5d6ce17 100644 --- a/errors.go +++ b/errors.go @@ -66,6 +66,13 @@ var ( // take a File beyond these limits fails with it. ErrLoadLimit = errors.New("gitconfig: exceeded load limits") + // ErrRemoteURLInInclude is returned, as git refuses it, when a remote + // URL is read from a file included by an includeIf in a load that also + // meets an includeIf "hasconfig:remote.*.url:" condition: such a URL + // could change what the condition matches. The *FileError names the + // file of the URL. + ErrRemoteURLInInclude = errors.New("gitconfig: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url") + // ErrNotRegular is returned when a configuration file or an include is // not a regular file (a directory, a FIFO, a device). ErrNotRegular = errors.New("gitconfig: not a regular file") diff --git a/glob.go b/glob.go index 333e883..3b0e8da 100644 --- a/glob.go +++ b/glob.go @@ -2,7 +2,9 @@ package gitconfig import "strings" -// The patterns of includeIf "gitdir:" and "onbranch:" are matched as paths: +// The patterns of includeIf "gitdir:", "onbranch:" and +// "hasconfig:remote.*.url:" are matched as paths (URLs included, which get +// no implicit "**/" or trailing "**"): // '*' and '?' do not match '/', "**/" at the start of a segment matches any // number of directories, "**" ending the pattern after a '/' (or alone) // matches anything, a segment of more stars ("***") is "**", "[...]" is a class of characters ("[!...]" or "[^...]" @@ -189,9 +191,12 @@ func sameByte(a, b byte, fold bool) bool { // ASCII letters is ignored. func globMatch(pattern, name string, fold bool) bool { toks, ok := compileGlob(pattern) - if !ok { - return false - } + return ok && matchTokens(toks, name, fold) +} + +// matchTokens reports whether name matches the compiled pattern toks, so +// that a pattern matched against many names is compiled once. +func matchTokens(toks []globToken, name string, fold bool) bool { n := int32(len(toks)) // A state is a position in the pattern, 2*s for token s, or 2*s+1 for // the inside of the directories matched by the globDirs token s. Only diff --git a/interop_test.go b/interop_test.go index 2638ff0..6ae6f2d 100644 --- a/interop_test.go +++ b/interop_test.go @@ -665,6 +665,13 @@ type gitScenario struct { setup func(t *testing.T, root string) (workDir, gitDir string) // fails is true if git, and Load, must fail. fails bool + // remoteURLErr is true if they fail on a remote URL included under a + // hasconfig:remote.*.url: condition, rather than on another error. + remoteURLErr bool + // globalAlone is true if the global layer is ~/.gitconfig alone and its + // conditions read other scopes: LoadScope(ScopeGlobal) is then compared + // with `git config --global --includes`, which reads that file alone. + globalAlone bool } // gitInit runs `git init` in dir with the current environment. @@ -867,6 +874,98 @@ var gitScenarios = []gitScenario{ return work, filepath.Join(work, ".git") }, }, + { + name: "includeIf hasconfig", + globalAlone: true, + setup: func(t *testing.T, root string) (string, string) { + home := os.Getenv("HOME") + conds := []string{ + "hasconfig:remote.*.url:git@github.com:work/**", + "hasconfig:remote.*.url:git@github.com:*", + "hasconfig:remote.*.url:https://example.com/a/*", + "hasconfig:remote.*.url:https://example.com/*", + "hasconfig:remote.*.url:https://EXAMPLE.com/**", + "hasconfig:remote.*.url:example.com/**", + "hasconfig:remote.*.url:https://cmd/**", + "hasconfig:remote.*.url:https://later/**", + "hasconfig:remote.*.url:**", + "hasconfig:remote.*.url:*", + "hasconfig:remote.origin.url:**", + "hasconfig:remote.*.pushurl:**", + "hasconfig:user.name:**", + } + var global strings.Builder + for i, c := range conds { + fmt.Fprintf(&global, "[includeIf %q]\n\tpath = c%d\n", c, i) + writeFile(t, filepath.Join(home, fmt.Sprintf("c%d", i)), fmt.Sprintf("[cond]\n\tc%d = %s\n[include]\n\tpath = n%d\n", i, c, i)) + writeFile(t, filepath.Join(home, fmt.Sprintf("n%d", i)), fmt.Sprintf("[cond]\n\tn%d = nested\n", i)) + } + global.WriteString("[user]\n\tname = after\n[remote \"later\"]\n\turl = https://later/x\n") + writeFile(t, filepath.Join(home, ".gitconfig"), global.String()) + + work := filepath.Join(root, "proj") + gitInit(t, work) + appendFile(t, filepath.Join(work, ".git", "config"), + "[remote \"origin\"]\n\turl = git@github.com:work/repo.git\n\tpushurl = p\n[remote \"other\"]\n\turl = https://example.com/a/b\n"+ + "[includeIf \"hasconfig:remote.*.url:https://example.com/**\"]\n\tpath = ../local-inc\n") + writeFile(t, filepath.Join(work, "local-inc"), "[cond]\n\tlocal = yes\n") + t.Setenv("GIT_CONFIG_PARAMETERS", "'remote.cmd.url'='https://cmd/x'") + return work, filepath.Join(work, ".git") + }, + }, + { + name: "hasconfig: remote URL in its include", + fails: true, remoteURLErr: true, + setup: func(t *testing.T, root string) (string, string) { + home := os.Getenv("HOME") + work := filepath.Join(root, "proj") + // git init fails once the global file is in place. + gitInit(t, work) + writeFile(t, filepath.Join(home, ".gitconfig"), "[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tpath = inc\n") + writeFile(t, filepath.Join(home, "inc"), "[remote \"o\"]\n\turl = u\n") + return work, filepath.Join(work, ".git") + }, + }, + { + name: "hasconfig: condition with no path", + fails: true, remoteURLErr: true, + setup: func(t *testing.T, root string) (string, string) { + home := os.Getenv("HOME") + work := filepath.Join(root, "proj") + gitInit(t, work) + writeFile(t, filepath.Join(home, ".gitconfig"), fmt.Sprintf( + "[includeIf \"gitdir:%s/\"]\n\tpath = inc\n[includeIf \"hasconfig:remote.*.url:nomatch\"]\n\tfoo = bar\n", filepath.ToSlash(root))) + writeFile(t, filepath.Join(home, "inc"), "[remote \"o\"]\n\turl = u\n") + return work, filepath.Join(work, ".git") + }, + }, + { + name: "hasconfig: remote URL before a syntax error", + fails: true, remoteURLErr: true, + setup: func(t *testing.T, root string) (string, string) { + home := os.Getenv("HOME") + work := filepath.Join(root, "proj") + gitInit(t, work) + writeFile(t, filepath.Join(home, ".gitconfig"), "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = inc\n") + writeFile(t, filepath.Join(home, "inc"), "[remote \"o\"]\n\turl = u\n") + appendFile(t, filepath.Join(work, ".git", "config"), "bad line\n") + return work, filepath.Join(work, ".git") + }, + }, + { + name: "hasconfig: remote URL in a gitdir include", + fails: true, remoteURLErr: true, + setup: func(t *testing.T, root string) (string, string) { + home := os.Getenv("HOME") + work := filepath.Join(root, "proj") + // git init fails once the global file is in place. + gitInit(t, work) + writeFile(t, filepath.Join(home, ".gitconfig"), fmt.Sprintf( + "[includeIf \"hasconfig:remote.*.url:**\"]\n\tpath = missing\n[includeIf \"gitdir:%s/\"]\n\tpath = inc\n", filepath.ToSlash(root))) + writeFile(t, filepath.Join(home, "inc"), "[remote \"o\"]\n\turl = u\n") + return work, filepath.Join(work, ".git") + }, + }, { name: "include cycle", fails: true, @@ -939,6 +1038,8 @@ func TestGitLoad(t *testing.T) { if sc.fails { require.NotZero(t, res.code, "git succeeds") require.Error(t, err) + assert.Equal(t, sc.remoteURLErr, strings.Contains(res.stderr, "remote URLs cannot be configured"), res.stderr) + assert.Equal(t, sc.remoteURLErr, errors.Is(err, gitconfig.ErrRemoteURLInInclude), "%v", err) return } require.Zero(t, res.code, res.stderr) @@ -965,7 +1066,7 @@ func TestGitLoad(t *testing.T) { // The scope itself is not compared: git labels // config.worktree "local" when asked for --worktree. t.Run("LoadScope "+s.String(), func(t *testing.T) { - if s == gitconfig.ScopeGlobal { + if s == gitconfig.ScopeGlobal && !sc.globalAlone { // `git config --global` reads only the file it // writes; LoadScope reads the whole global layer, as // git does when it reads every scope. @@ -1073,3 +1174,112 @@ func TestGitTrust(t *testing.T) { }) } } + +// FuzzGitLoadIncludes builds a small configuration from data, global and +// local files including each other under every kind of condition, and +// checks that Load and LoadScope agree with git, or fail where it fails. +// Run with -fuzz: it starts git three times per input. +func FuzzGitLoadIncludes(f *testing.F) { + requireGit(f) + root := tempDir(f) + home := filepath.Join(root, "home") + work := filepath.Join(root, "work") + gitDir := filepath.Join(work, ".git") + require.NoError(f, os.MkdirAll(home, 0o755)) + initRepo(f, gitDir, "") + + urls := []string{"https://example.com/a/b", "git@github.com:work/r.git", "https://x/y", "u", ""} + globs := []string{"**", "https://example.com/**", "https://example.com/*", "https://example.com/a/*", + "git@github.com:work/**", "git@github.com:*", "**/b", "*", "u", "nomatch", ""} + conds := []string{"gitdir:" + filepath.ToSlash(root) + "/", "gitdir:/nope/", "onbranch:main", "onbranch:other"} + const nIncludes = 4 + inc := func(i int) string { return filepath.Join(home, fmt.Sprintf("i%d", i)) } + + for _, s := range []string{"", "\x00\x01\x02", "\x03\x00\x05\x01\x02\x07", "\x05\x01\x05\x02\x09\x03\x00\x01", + "\x09\x09\x09\x09\x01\x02\x03\x04\x05\x06\x07\x08", "\x02\x00\x03\x01\x02\x03\x00\x02\x04\x05\x00\x00\x00\x01\x02"} { + f.Add([]byte(s)) + } + f.Fuzz(func(t *testing.T, data []byte) { + next := func(n int) int { + if len(data) == 0 { + return 0 + } + b := data[0] + data = data[1:] + return int(b) % n + } + // file renders up to 6 lines; remote URLs are never implicit, git + // crashes on them. + file := func() string { + var sb strings.Builder + for range next(7) { + switch next(9) { + case 0: + fmt.Fprintf(&sb, "[user]\n\tname = n%d\n", next(10)) + case 1, 2: + fmt.Fprintf(&sb, "[remote \"r%d\"]\n\turl = %s\n", next(3), urls[next(len(urls))]) + case 3, 4: + fmt.Fprintf(&sb, "[includeIf %q]\n\tpath = %s\n", "hasconfig:remote.*.url:"+globs[next(len(globs))], inc(next(nIncludes))) + case 5: + fmt.Fprintf(&sb, "[includeIf %q]\n\tpath = %s\n", conds[next(len(conds))], inc(next(nIncludes))) + case 6: + fmt.Fprintf(&sb, "[include]\n\tpath = ~/i%d\n", next(nIncludes)) + case 7: + fmt.Fprintf(&sb, "[remote \"r%d\"]\n\tpushurl = %s\n", next(3), urls[next(len(urls))]) + case 8: + if next(4) == 0 { + sb.WriteString("bad line\n") + } + } + } + return sb.String() + } + writeFile(t, filepath.Join(home, ".gitconfig"), file()) + writeFile(t, filepath.Join(gitDir, "config"), "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"+file()) + for i := range nIncludes { + writeFile(t, inc(i), file()) + } + env := gitconfig.Env{Home: home, GlobalConfig: []string{filepath.Join(home, ".gitconfig")}, GitDir: gitDir} + gitEnv := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(root, "xdg"), "GIT_CONFIG_NOSYSTEM=1", + "LC_ALL=C", "LANGUAGE=C", "PATH=" + os.Getenv("PATH")} + if n := next(3); n > 0 { + url := urls[next(len(urls))] + env.Command = []gitconfig.CommandEntry{{Key: "remote.c.url", Value: url}} + gitEnv = append(gitEnv, "GIT_CONFIG_PARAMETERS='remote.c.url'='"+url+"'") + } + + check := func(name string, got *gitconfig.Config, err error, withScope bool, args ...string) bool { + t.Helper() + res := runGit(t, work, gitEnv, args...) + if res.code != 0 { + require.Error(t, err, "%s: git fails: %s", name, res.stderr) + return false + } + require.NoError(t, err, "%s: git succeeds", name) + n := 2 + if withScope { + n = 3 + } + var want []string + for _, r := range splitZ(t, res.stdout, n) { + if withScope { + want = append(want, r[0]+"\t"+gitOrigin(work, r[1])+"\t"+gitKeyValue(r[2])) + } else { + want = append(want, gitOrigin(work, r[0])+"\t"+gitKeyValue(r[1])) + } + } + require.Equal(t, want, describe(slices.Collect(got.All()), withScope), name) + return true + } + c, err := env.Load() + if !check("Load", c, err, true, "config", "--list", "--show-scope", "--show-origin", "-z") { + // git reads every scope before it reads one: it would fail + // where LoadScope reads its scope alone. + return + } + c, err = env.LoadScope(gitconfig.ScopeGlobal) + check("LoadScope global", c, err, false, "config", "--global", "--includes", "--list", "--show-origin", "-z") + c, err = env.LoadScope(gitconfig.ScopeLocal) + check("LoadScope local", c, err, false, "config", "--local", "--includes", "--list", "--show-origin", "-z") + }) +} diff --git a/load.go b/load.go index fd56c2c..3f59e8b 100644 --- a/load.go +++ b/load.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "syscall" ) @@ -213,6 +214,30 @@ type loader struct { realDirRead bool conds map[condKey]bool // includeIf conditions evaluated so far + + // includeIf "hasconfig:remote.*.url:" needs every remote URL of the + // load, including those read after it: its includes are followed, and + // config drops them if no URL matches. + urls []string // the remote URLs read + urlCond bool // such a condition was met + urlIncludes []urlInclude // the includes it followed, by start + inIncludeIf int // includeIf includes being loaded + condURL *Origin // of the first remote URL read from one of them +} + +// urlInclude is an include of "hasconfig:remote.*.url:", and the +// entries it read: l.entries[start:end]. Two of them are nested or +// disjoint. +type urlInclude struct { + glob string + start, end int +} + +const urlCondPrefix = "hasconfig:remote.*.url:" + +// isRemoteURL reports whether k is a remote URL, remote..url. +func isRemoteURL(k Key) bool { + return k.Section == "remote" && k.HasSubsection && k.Name == "url" } // condKey is an includeIf condition and the file that holds it, which @@ -227,8 +252,63 @@ func (e Env) newLoader() (*loader, error) { return &loader{env: e, repo: r, budget: maxLoadEntries}, nil } +// config returns the configuration loaded. Once every remote URL is known, +// the includes of the hasconfig:remote.*.url: conditions that match none +// are dropped. func (l *loader) config() *Config { - return newConfig(l.entries) + if len(l.urlIncludes) == 0 { + return newConfig(l.entries) + } + matches := make(map[string]bool) + // A new slice: l.entries and the offsets of l.urlIncludes stay valid. + entries := make([]Entry, 0, len(l.entries)) + next := 0 // l.entries[:next] are copied or dropped + for _, inc := range l.urlIncludes { + if inc.start < next { + // Within an include already dropped. + continue + } + ok, found := matches[inc.glob] + if !found { + toks, valid := compileGlob(inc.glob) + ok = valid && slices.ContainsFunc(l.urls, func(url string) bool { return matchTokens(toks, url, false) }) + matches[inc.glob] = ok + } + if !ok { + entries = append(entries, l.entries[next:inc.start]...) + next = inc.end + } + } + return newConfig(append(entries, l.entries[next:]...)) +} + +// add appends e to the entries. It notes a remote URL for +// hasconfig:remote.*.url:, and fails as git does on one read from a file +// included by an includeIf once such a condition is met. +func (l *loader) add(e Entry) error { + l.entries = append(l.entries, e) + if !isRemoteURL(e.Key) { + return nil + } + // An implicit URL is no URL (git crashes on it). + if !e.Implicit { + l.urls = append(l.urls, e.Value) + } + if l.inIncludeIf > 0 && l.condURL == nil { + l.condURL = &e.Origin + return l.urlError() + } + return nil +} + +// urlError returns git's error once a hasconfig:remote.*.url: condition is +// met and a remote URL was read from a file included by an includeIf, in +// either order, matching or not. +func (l *loader) urlError() error { + if o := l.condURL; l.urlCond && o != nil { + return &FileError{Path: o.File, Err: fmt.Errorf("%w (line %d)", ErrRemoteURLInInclude, o.Line)} + } + return nil } // loadFile loads the file at path for scope. A missing file is empty. If @@ -266,7 +346,9 @@ func (l *loader) load(path string, scope Scope, depth int) error { // is reported before a syntax error further down this file. var includeErr error err = parseEntries(path, data, scope, &l.budget, func(e Entry) error { - l.entries = append(l.entries, e) + if includeErr = l.add(e); includeErr != nil { + return includeErr + } includeErr = l.include(e, path, scope, depth) return includeErr }) @@ -282,15 +364,30 @@ func (l *loader) load(path string, scope Scope, depth int) error { // include follows e if it is an include directive. from is the file that // holds it, "" for the command line. func (l *loader) include(e Entry, from string, scope Scope, depth int) error { - if e.Key.Name != "path" { - return nil - } + urlGlob, isURLCond := "", false switch { case e.Key.Section == "include" && !e.Key.HasSubsection: + if e.Key.Name != "path" { + return nil + } case e.Key.Section == "includeif" && e.Key.HasSubsection: - ok, err := l.condition(e.Key.Subsection, from) - if err != nil || !ok { - return err + // As in git, a hasconfig:remote.*.url: condition is met by any key + // of its section, path or not. + if urlGlob, isURLCond = strings.CutPrefix(e.Key.Subsection, urlCondPrefix); isURLCond { + l.urlCond = true + if err := l.urlError(); err != nil { + return err + } + } + if e.Key.Name != "path" { + return nil + } + // A hasconfig:remote.*.url: include is followed for now: see config. + if !isURLCond { + ok, err := l.condition(e.Key.Subsection, from) + if err != nil || !ok { + return err + } } default: return nil @@ -316,7 +413,20 @@ func (l *loader) include(e Entry, from string, scope Scope, depth int) error { if depth+1 > maxIncludeDepth { return &FileError{Path: path, Err: fmt.Errorf("%w while including %s from %s; this might be due to circular includes", ErrIncludeDepth, path, from)} } - return l.load(path, scope, depth+1) + if e.Key.Section == "includeif" { + l.inIncludeIf++ + defer func() { l.inIncludeIf-- }() + } + // Added before loading, so that it comes before the includes it holds. + i := len(l.urlIncludes) + if isURLCond { + l.urlIncludes = append(l.urlIncludes, urlInclude{urlGlob, len(l.entries), 0}) + } + err = l.load(path, scope, depth+1) + if isURLCond { + l.urlIncludes[i].end = len(l.entries) + } + return err } // fail reports an invalid include directive, as git does: a bad line of @@ -357,9 +467,9 @@ func (l *loader) condition(cond, from string) (bool, error) { return ok, nil } -// evalCondition evaluates the condition of an includeIf. Unknown -// conditions, and those this package does not support (hasconfig:), are -// false. +// evalCondition evaluates the condition of an includeIf, but +// hasconfig:remote.*.url:, which include and config handle. Unknown +// conditions are false. func (l *loader) evalCondition(cond, from string) (bool, error) { switch { case strings.HasPrefix(cond, "gitdir:"): @@ -458,7 +568,9 @@ func (l *loader) command() error { if c.Implicit { e.Value = "" } - l.entries = append(l.entries, e) + if err := l.add(e); err != nil { + return err + } if err := l.include(e, "", ScopeCommand, 0); err != nil { return err }