git.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package remote
  14. import (
  15. "bufio"
  16. "bytes"
  17. "context"
  18. "fmt"
  19. "os"
  20. "os/exec"
  21. "path/filepath"
  22. "regexp"
  23. "strconv"
  24. "strings"
  25. "github.com/compose-spec/compose-go/v2/cli"
  26. "github.com/compose-spec/compose-go/v2/loader"
  27. "github.com/compose-spec/compose-go/v2/types"
  28. "github.com/docker/cli/cli/command"
  29. "github.com/docker/compose/v2/pkg/api"
  30. gitutil "github.com/moby/buildkit/frontend/dockerfile/dfgitutil"
  31. "github.com/sirupsen/logrus"
  32. )
  33. const GIT_REMOTE_ENABLED = "COMPOSE_EXPERIMENTAL_GIT_REMOTE"
  34. func gitRemoteLoaderEnabled() (bool, error) {
  35. if v := os.Getenv(GIT_REMOTE_ENABLED); v != "" {
  36. enabled, err := strconv.ParseBool(v)
  37. if err != nil {
  38. return false, fmt.Errorf("COMPOSE_EXPERIMENTAL_GIT_REMOTE environment variable expects boolean value: %w", err)
  39. }
  40. return enabled, err
  41. }
  42. return true, nil
  43. }
  44. func NewGitRemoteLoader(dockerCli command.Cli, offline bool) loader.ResourceLoader {
  45. return gitRemoteLoader{
  46. dockerCli: dockerCli,
  47. offline: offline,
  48. known: map[string]string{},
  49. }
  50. }
  51. type gitRemoteLoader struct {
  52. dockerCli command.Cli
  53. offline bool
  54. known map[string]string
  55. }
  56. func (g gitRemoteLoader) Accept(path string) bool {
  57. _, _, err := gitutil.ParseGitRef(path)
  58. return err == nil
  59. }
  60. var commitSHA = regexp.MustCompile(`^[a-f0-9]{40}$`)
  61. func (g gitRemoteLoader) Load(ctx context.Context, path string) (string, error) {
  62. enabled, err := gitRemoteLoaderEnabled()
  63. if err != nil {
  64. return "", err
  65. }
  66. if !enabled {
  67. return "", fmt.Errorf("git remote resource is disabled by %q", GIT_REMOTE_ENABLED)
  68. }
  69. ref, _, err := gitutil.ParseGitRef(path)
  70. if err != nil {
  71. return "", err
  72. }
  73. local, ok := g.known[path]
  74. if !ok {
  75. if ref.Ref == "" {
  76. ref.Ref = "HEAD" // default branch
  77. }
  78. err = g.resolveGitRef(ctx, path, ref)
  79. if err != nil {
  80. return "", err
  81. }
  82. cache, err := cacheDir()
  83. if err != nil {
  84. return "", fmt.Errorf("initializing remote resource cache: %w", err)
  85. }
  86. local = filepath.Join(cache, ref.Ref)
  87. if _, err := os.Stat(local); os.IsNotExist(err) {
  88. if g.offline {
  89. return "", nil
  90. }
  91. err = g.checkout(ctx, local, ref)
  92. if err != nil {
  93. return "", err
  94. }
  95. }
  96. g.known[path] = local
  97. }
  98. if ref.SubDir != "" {
  99. if err := validateGitSubDir(local, ref.SubDir); err != nil {
  100. return "", err
  101. }
  102. local = filepath.Join(local, ref.SubDir)
  103. }
  104. stat, err := os.Stat(local)
  105. if err != nil {
  106. return "", err
  107. }
  108. if stat.IsDir() {
  109. local, err = findFile(cli.DefaultFileNames, local)
  110. }
  111. return local, err
  112. }
  113. func (g gitRemoteLoader) Dir(path string) string {
  114. return g.known[path]
  115. }
  116. // validateGitSubDir ensures a subdirectory path is contained within the base directory
  117. // and doesn't escape via path traversal. Unlike validatePathInBase for OCI artifacts,
  118. // this allows nested directories but prevents traversal outside the base.
  119. func validateGitSubDir(base, subDir string) error {
  120. cleanSubDir := filepath.Clean(subDir)
  121. if filepath.IsAbs(cleanSubDir) {
  122. return fmt.Errorf("git subdirectory must be relative, got: %s", subDir)
  123. }
  124. if cleanSubDir == ".." || strings.HasPrefix(cleanSubDir, "../") || strings.HasPrefix(cleanSubDir, "..\\") {
  125. return fmt.Errorf("git subdirectory path traversal detected: %s", subDir)
  126. }
  127. if len(cleanSubDir) >= 2 && cleanSubDir[1] == ':' {
  128. return fmt.Errorf("git subdirectory must be relative, got: %s", subDir)
  129. }
  130. targetPath := filepath.Join(base, cleanSubDir)
  131. cleanBase := filepath.Clean(base)
  132. cleanTarget := filepath.Clean(targetPath)
  133. // Ensure the target starts with the base path
  134. relPath, err := filepath.Rel(cleanBase, cleanTarget)
  135. if err != nil {
  136. return fmt.Errorf("invalid git subdirectory path: %w", err)
  137. }
  138. if relPath == ".." || strings.HasPrefix(relPath, "../") || strings.HasPrefix(relPath, "..\\") {
  139. return fmt.Errorf("git subdirectory escapes base directory: %s", subDir)
  140. }
  141. return nil
  142. }
  143. func (g gitRemoteLoader) resolveGitRef(ctx context.Context, path string, ref *gitutil.GitRef) error {
  144. if !commitSHA.MatchString(ref.Ref) {
  145. cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", ref.Remote, ref.Ref)
  146. cmd.Env = g.gitCommandEnv()
  147. out, err := cmd.CombinedOutput()
  148. if err != nil {
  149. if cmd.ProcessState.ExitCode() == 2 {
  150. return fmt.Errorf("repository does not contain ref %s, output: %q: %w", path, string(out), err)
  151. }
  152. return fmt.Errorf("failed to access repository at %s:\n %s", ref.Remote, out)
  153. }
  154. if len(out) < 40 {
  155. return fmt.Errorf("unexpected git command output: %q", string(out))
  156. }
  157. sha := string(out[:40])
  158. if !commitSHA.MatchString(sha) {
  159. return fmt.Errorf("invalid commit sha %q", sha)
  160. }
  161. ref.Ref = sha
  162. }
  163. return nil
  164. }
  165. func (g gitRemoteLoader) checkout(ctx context.Context, path string, ref *gitutil.GitRef) error {
  166. err := os.MkdirAll(path, 0o700)
  167. if err != nil {
  168. return err
  169. }
  170. err = exec.CommandContext(ctx, "git", "init", path).Run()
  171. if err != nil {
  172. return err
  173. }
  174. cmd := exec.CommandContext(ctx, "git", "remote", "add", "origin", ref.Remote)
  175. cmd.Dir = path
  176. err = cmd.Run()
  177. if err != nil {
  178. return err
  179. }
  180. cmd = exec.CommandContext(ctx, "git", "fetch", "--depth=1", "origin", ref.Ref)
  181. cmd.Env = g.gitCommandEnv()
  182. cmd.Dir = path
  183. err = g.run(cmd)
  184. if err != nil {
  185. return err
  186. }
  187. cmd = exec.CommandContext(ctx, "git", "checkout", ref.Ref)
  188. cmd.Dir = path
  189. err = cmd.Run()
  190. if err != nil {
  191. return err
  192. }
  193. return nil
  194. }
  195. func (g gitRemoteLoader) run(cmd *exec.Cmd) error {
  196. if logrus.IsLevelEnabled(logrus.DebugLevel) {
  197. output, err := cmd.CombinedOutput()
  198. scanner := bufio.NewScanner(bytes.NewBuffer(output))
  199. for scanner.Scan() {
  200. line := scanner.Text()
  201. logrus.Debug(line)
  202. }
  203. return err
  204. }
  205. return cmd.Run()
  206. }
  207. func (g gitRemoteLoader) gitCommandEnv() []string {
  208. env := types.NewMapping(os.Environ())
  209. if env["GIT_TERMINAL_PROMPT"] == "" {
  210. // Disable prompting for passwords by Git until user explicitly asks for it.
  211. env["GIT_TERMINAL_PROMPT"] = "0"
  212. }
  213. if env["GIT_SSH"] == "" && env["GIT_SSH_COMMAND"] == "" {
  214. // Disable any ssh connection pooling by Git and do not attempt to prompt the user.
  215. env["GIT_SSH_COMMAND"] = "ssh -o ControlMaster=no -o BatchMode=yes"
  216. }
  217. v := env.Values()
  218. return v
  219. }
  220. func findFile(names []string, pwd string) (string, error) {
  221. for _, n := range names {
  222. f := filepath.Join(pwd, n)
  223. if fi, err := os.Stat(f); err == nil && !fi.IsDir() {
  224. return f, nil
  225. }
  226. }
  227. return "", api.ErrNotFound
  228. }
  229. var _ loader.ResourceLoader = gitRemoteLoader{}