github.com/tickoalcantara12/micro/v3@v3.0.0-20221007104245-9d75b9bcbab9/service/runtime/source/git/git.go (about) 1 // Licensed under the Apache License, Version 2.0 (the "License"); 2 // you may not use this file except in compliance with the License. 3 // You may obtain a copy of the License at 4 // 5 // https://www.apache.org/licenses/LICENSE-2.0 6 // 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 // Original source: github.com/micro/go-micro/v3/runtime/local/source/git/git.go 14 15 package git 16 17 import ( 18 "archive/tar" 19 "archive/zip" 20 "compress/gzip" 21 "fmt" 22 "io" 23 "io/ioutil" 24 "net/http" 25 "os" 26 "os/exec" 27 "path" 28 "path/filepath" 29 "regexp" 30 "strings" 31 32 "github.com/teris-io/shortid" 33 ) 34 35 const credentialsKey = "GIT_CREDENTIALS" 36 37 type Gitter interface { 38 Checkout(repo, branchOrCommit string) error 39 RepoDir() string 40 } 41 42 type binaryGitter struct { 43 folder string 44 secrets map[string]string 45 client *http.Client 46 } 47 48 func (g *binaryGitter) Checkout(repo, branchOrCommit string) error { 49 // The implementation of this method is questionable. 50 // We use archives from github/gitlab etc which doesnt require the user to have got 51 // and probably is faster than downloading the whole repo history, 52 // but it comes with a bit of custom code for EACH host. 53 // @todo probably we should fall back to git in case the archives are not available. 54 doCheckout := func(repo, branchOrCommit string) error { 55 if strings.HasPrefix(repo, "https://github.com") { 56 return g.checkoutGithub(repo, branchOrCommit) 57 } else if strings.HasPrefix(repo, "https://gitlab.com") { 58 err := g.checkoutGitLabPublic(repo, branchOrCommit) 59 if err != nil && len(g.secrets[credentialsKey]) > 0 { 60 // If the public download fails, try getting it with tokens. 61 // Private downloads needs a token for api project listing, hence 62 // the weird structure of this code. 63 return g.checkoutGitLabPrivate(repo, branchOrCommit) 64 } 65 return err 66 } 67 if len(g.secrets[credentialsKey]) > 0 { 68 return g.checkoutAnyRemote(repo, branchOrCommit, true) 69 } 70 return g.checkoutAnyRemote(repo, branchOrCommit, false) 71 } 72 73 if branchOrCommit != "latest" { 74 return doCheckout(repo, branchOrCommit) 75 } 76 // default branches 77 defaults := []string{"latest", "master", "main", "trunk"} 78 var err error 79 for _, ref := range defaults { 80 err = doCheckout(repo, ref) 81 if err == nil { 82 return nil 83 } 84 } 85 return err 86 } 87 88 // This aims to be a generic checkout method. Currently only tested for bitbucket, 89 // see tests 90 func (g *binaryGitter) checkoutAnyRemote(repo, branchOrCommit string, useCredentials bool) error { 91 repoFolder := strings.ReplaceAll(strings.ReplaceAll(repo, "/", "-"), "https:--", "") 92 g.folder = filepath.Join(os.TempDir(), 93 repoFolder+"-"+shortid.MustGenerate()) 94 err := os.MkdirAll(g.folder, 0755) 95 if err != nil { 96 return err 97 } 98 99 // Assumes remote address format is git@gitlab.com:micro-test/monorepo-test.git 100 remoteAddr := fmt.Sprintf("https://%v", strings.TrimPrefix(repo, "https://")) 101 if useCredentials { 102 remoteAddr = fmt.Sprintf("https://%v@%v", g.secrets[credentialsKey], repo) 103 } 104 105 cmd := exec.Command("git", "clone", remoteAddr, "--depth=1", ".") 106 cmd.Dir = g.folder 107 outp, err := cmd.CombinedOutput() 108 if err != nil { 109 return fmt.Errorf("Git clone failed: %v", string(outp)) 110 } 111 112 cmd = exec.Command("git", "fetch", "origin", branchOrCommit, "--depth=1") 113 cmd.Dir = g.folder 114 outp, err = cmd.CombinedOutput() 115 if err != nil { 116 return fmt.Errorf("Git fetch failed: %v", string(outp)) 117 } 118 119 cmd = exec.Command("git", "checkout", "FETCH_HEAD") 120 cmd.Dir = g.folder 121 outp, err = cmd.CombinedOutput() 122 if err != nil { 123 return fmt.Errorf("Git checkout failed: %v", string(outp)) 124 } 125 return nil 126 } 127 128 func (g *binaryGitter) checkoutGithub(repo, branchOrCommit string) error { 129 // @todo if it's a commit it must not be checked out all the time 130 repoFolder := strings.ReplaceAll(strings.ReplaceAll(repo, "/", "-"), "https:--", "") 131 g.folder = filepath.Join(os.TempDir(), 132 repoFolder+"-"+shortid.MustGenerate()) 133 134 url := fmt.Sprintf("%v/archive/%v.zip", repo, branchOrCommit) 135 if !strings.HasPrefix(url, "https://") { 136 url = "https://" + url 137 } 138 req, _ := http.NewRequest("GET", url, nil) 139 if len(g.secrets[credentialsKey]) > 0 { 140 req.Header.Set("Authorization", "token "+g.secrets[credentialsKey]) 141 } 142 resp, err := g.client.Do(req) 143 if err != nil { 144 return fmt.Errorf("Can't get zip: %v", err) 145 } 146 147 defer resp.Body.Close() 148 // Github returns 404 for tar.gz files... 149 // but still gives back a proper file so ignoring status code 150 // for now. 151 //if resp.StatusCode != 200 { 152 // return errors.New("Status code was not 200") 153 //} 154 155 src := g.folder + ".zip" 156 // Create the file 157 out, err := os.Create(src) 158 if err != nil { 159 return fmt.Errorf("Can't create source file %v src: %v", src, err) 160 } 161 defer out.Close() 162 163 // Write the body to file 164 _, err = io.Copy(out, resp.Body) 165 if err != nil { 166 return err 167 } 168 return unzip(src, g.folder, true) 169 } 170 171 func (g *binaryGitter) checkoutGitLabPublic(repo, branchOrCommit string) error { 172 // Example: https://gitlab.com/micro-test/basic-micro-service/-/archive/master/basic-micro-service-master.tar.gz 173 // @todo if it's a commit it must not be checked out all the time 174 repoFolder := strings.ReplaceAll(strings.ReplaceAll(repo, "/", "-"), "https:--", "") 175 g.folder = filepath.Join(os.TempDir(), 176 repoFolder+"-"+shortid.MustGenerate()) 177 178 tarName := strings.ReplaceAll(strings.ReplaceAll(repo, "gitlab.com/", ""), "/", "-") 179 url := fmt.Sprintf("%v/-/archive/%v/%v.tar.gz", repo, branchOrCommit, tarName) 180 if !strings.HasPrefix(url, "https://") { 181 url = "https://" + url 182 } 183 req, _ := http.NewRequest("GET", url, nil) 184 resp, err := g.client.Do(req) 185 if err != nil { 186 return fmt.Errorf("Can't get zip: %v", err) 187 } 188 189 defer resp.Body.Close() 190 191 src := g.folder + ".tar.gz" 192 // Create the file 193 out, err := os.Create(src) 194 if err != nil { 195 return fmt.Errorf("Can't create source file %v src: %v", src, err) 196 } 197 defer out.Close() 198 199 // Write the body to file 200 _, err = io.Copy(out, resp.Body) 201 if err != nil { 202 return err 203 } 204 err = Uncompress(src, g.folder) 205 if err != nil { 206 return err 207 } 208 // Gitlab zip/tar has contents inside a folder 209 // It has the format of eg. basic-micro-service-master-314b4a494ed472793e0a8bce8babbc69359aed7b 210 // Since we don't have the commit at this point we must list the dir 211 files, err := ioutil.ReadDir(g.folder) 212 if err != nil { 213 return err 214 } 215 if len(files) == 0 { 216 return fmt.Errorf("No contents in dir downloaded from gitlab: %v", g.folder) 217 } 218 g.folder = filepath.Join(g.folder, files[0].Name()) 219 return nil 220 } 221 222 func (g *binaryGitter) checkoutGitLabPrivate(repo, branchOrCommit string) error { 223 224 repoFolder := strings.ReplaceAll(strings.ReplaceAll(repo, "/", "-"), "https:--", "") 225 g.folder = filepath.Join(os.TempDir(), 226 repoFolder+"-"+shortid.MustGenerate()) 227 tarName := strings.ReplaceAll(strings.ReplaceAll(repo, "gitlab.com/", ""), "/", "-") 228 229 url := fmt.Sprintf("%v/-/archive/%v/%v.tar.gz?private_token=%v", repo, branchOrCommit, tarName, g.secrets[credentialsKey]) 230 231 if !strings.HasPrefix(url, "https://") { 232 url = "https://" + url 233 } 234 req, _ := http.NewRequest("GET", url, nil) 235 resp, err := g.client.Do(req) 236 if err != nil { 237 return fmt.Errorf("Can't get zip: %v", err) 238 } 239 240 defer resp.Body.Close() 241 242 src := g.folder + ".tar.gz" 243 // Create the file 244 out, err := os.Create(src) 245 if err != nil { 246 return fmt.Errorf("Can't create source file %v src: %v", src, err) 247 } 248 defer out.Close() 249 250 // Write the body to file 251 _, err = io.Copy(out, resp.Body) 252 if err != nil { 253 return err 254 } 255 err = Uncompress(src, g.folder) 256 if err != nil { 257 return err 258 } 259 // Gitlab zip/tar has contents inside a folder 260 // It has the format of eg. basic-micro-service-master-314b4a494ed472793e0a8bce8babbc69359aed7b 261 // Since we don't have the commit at this point we must list the dir 262 files, err := ioutil.ReadDir(g.folder) 263 if err != nil { 264 return err 265 } 266 if len(files) == 0 { 267 return fmt.Errorf("No contents in dir downloaded from gitlab: %v", g.folder) 268 } 269 g.folder = filepath.Join(g.folder, files[0].Name()) 270 return nil 271 } 272 273 func (g *binaryGitter) RepoDir() string { 274 return g.folder 275 } 276 277 func NewGitter(secrets map[string]string) Gitter { 278 tmpdir, _ := ioutil.TempDir(os.TempDir(), "git-src-*") 279 280 return &binaryGitter{ 281 folder: tmpdir, 282 secrets: secrets, 283 client: &http.Client{}, 284 } 285 } 286 287 func commandExists(cmd string) bool { 288 _, err := exec.LookPath(cmd) 289 return err == nil 290 } 291 292 func dirifyRepo(s string) string { 293 s = strings.ReplaceAll(s, "https://", "") 294 s = strings.ReplaceAll(s, "/", "-") 295 return s 296 } 297 298 // exists returns whether the given file or directory exists 299 func pathExists(path string) (bool, error) { 300 _, err := os.Stat(path) 301 if err == nil { 302 return true, nil 303 } 304 if os.IsNotExist(err) { 305 return false, nil 306 } 307 return true, err 308 } 309 310 // GetRepoRoot determines the repo root from a full path. 311 // Returns empty string and no error if not found 312 func GetRepoRoot(fullPath string) (string, error) { 313 // traverse parent directories 314 prev := fullPath 315 for { 316 current := prev 317 318 // check for a go.mod 319 goExists, err := pathExists(filepath.Join(current, "go.mod")) 320 if err != nil { 321 return "", err 322 } 323 if goExists { 324 return current, nil 325 } 326 327 // check for .git 328 gitExists, err := pathExists(filepath.Join(current, ".git")) 329 if err != nil { 330 return "", err 331 } 332 if gitExists { 333 return current, nil 334 } 335 336 prev = filepath.Dir(current) 337 // reached top level, see: 338 // https://play.golang.org/p/rDgVdk3suzb 339 if current == prev { 340 break 341 } 342 } 343 return "", nil 344 } 345 346 // Source is not just git related @todo move 347 type Source struct { 348 // is it a local folder intended for a local runtime? 349 Local bool 350 // absolute path to service folder in local mode 351 FullPath string 352 // path of folder to repo root 353 // be it local or github repo 354 Folder string 355 // github ref 356 Ref string 357 // for cloning purposes 358 // blank for local 359 Repo string 360 // dir to repo root 361 // blank for non local 362 LocalRepoRoot string 363 } 364 365 // Name to be passed to RPC call runtime.Create Update Delete 366 // eg: `helloworld/api`, `crufter/myrepo/helloworld/api`, `localfolder` 367 func (s *Source) RuntimeName() string { 368 if len(s.Folder) == 0 { 369 // This is the case for top level url source ie. gitlab.com/micro-test/basic-micro-service 370 return path.Base(s.Repo) 371 } 372 return path.Base(s.Folder) 373 } 374 375 // Source to be passed to RPC call runtime.Create Update Delete 376 // eg: `helloworld`, `github.com/crufter/myrepo/helloworld`, `/path/to/localrepo/localfolder` 377 func (s *Source) RuntimeSource() string { 378 if s.Local && s.LocalRepoRoot != s.FullPath { 379 relpath, _ := filepath.Rel(s.LocalRepoRoot, s.FullPath) 380 return relpath 381 } 382 if s.Local { 383 return s.FullPath 384 } 385 if len(s.Folder) == 0 { 386 return s.Repo 387 } 388 return fmt.Sprintf("%v/%v", s.Repo, s.Folder) 389 } 390 391 // ParseSource parses a `micro run/update/kill` source. 392 func ParseSource(source string) (*Source, error) { 393 if !strings.Contains(source, "@") { 394 source += "@latest" 395 } 396 ret := &Source{} 397 refs := strings.Split(source, "@") 398 ret.Ref = refs[1] 399 parts := strings.Split(refs[0], "/") 400 401 max := 3 402 if len(parts) < 3 { 403 max = len(parts) 404 } 405 ret.Repo = strings.Join(parts[0:max], "/") 406 407 if len(parts) > 1 { 408 ret.Folder = strings.Join(parts[3:], "/") 409 } 410 411 return ret, nil 412 } 413 414 // ParseSourceLocal a version of ParseSource that detects and handles local paths. 415 // Workdir should be used only from the CLI @todo better interface for this function. 416 // PathExistsFunc exists only for testing purposes, to make the function side effect free. 417 func ParseSourceLocal(workDir, source string, pathExistsFunc ...func(path string) (bool, error)) (*Source, error) { 418 var pexists func(string) (bool, error) 419 if len(pathExistsFunc) == 0 { 420 pexists = pathExists 421 } else { 422 pexists = pathExistsFunc[0] 423 } 424 isLocal, localFullPath := IsLocal(workDir, source, pexists) 425 if isLocal { 426 localRepoRoot, err := GetRepoRoot(localFullPath) 427 if err != nil { 428 return nil, err 429 } 430 var folder string 431 // If the local repo root is a top level folder, we are not in a git repo. 432 // In this case, we should take the last folder as folder name. 433 if localRepoRoot == "" { 434 folder = filepath.Base(localFullPath) 435 } else { 436 folder = strings.ReplaceAll(localFullPath, localRepoRoot+string(filepath.Separator), "") 437 } 438 439 return &Source{ 440 Local: true, 441 Folder: folder, 442 FullPath: localFullPath, 443 LocalRepoRoot: localRepoRoot, 444 Ref: "latest", // @todo consider extracting branch from git here 445 }, nil 446 } 447 return ParseSource(source) 448 } 449 450 // IsLocal tries returns true and full path of directory if the path is a local one, and 451 // false and empty string if not. 452 func IsLocal(workDir, source string, pathExistsFunc ...func(path string) (bool, error)) (bool, string) { 453 var pexists func(string) (bool, error) 454 if len(pathExistsFunc) == 0 { 455 pexists = pathExists 456 } else { 457 pexists = pathExistsFunc[0] 458 } 459 // Check for absolute path 460 // @todo "/" won't work for Windows 461 if exists, err := pexists(source); strings.HasPrefix(source, "/") && err == nil && exists { 462 return true, source 463 // Check for path relative to workdir 464 } else if exists, err := pexists(filepath.Join(workDir, source)); err == nil && exists { 465 return true, filepath.Join(workDir, source) 466 } 467 return false, "" 468 } 469 470 // CheckoutSource checks out a git repo (source) into a local temp directory. It will return the 471 // source of the local repo an an error if one occured. Secrets can optionally be passed if the repo 472 // is private. 473 func CheckoutSource(source *Source, secrets map[string]string) (string, error) { 474 gitter := NewGitter(secrets) 475 repo := source.Repo 476 if !strings.Contains(repo, "https://") { 477 repo = "https://" + repo 478 } 479 if err := gitter.Checkout(repo, source.Ref); err != nil { 480 return "", err 481 } 482 return gitter.RepoDir(), nil 483 } 484 485 // code below is not used yet 486 487 var nameExtractRegexp = regexp.MustCompile(`((micro|web)\.Name\(")(.*)("\))`) 488 489 func extractServiceName(fileContent []byte) string { 490 hits := nameExtractRegexp.FindAll(fileContent, 1) 491 if len(hits) == 0 { 492 return "" 493 } 494 hit := string(hits[0]) 495 return strings.Split(hit, "\"")[1] 496 } 497 498 // Uncompress is a modified version of: https://gist.github.com/mimoo/25fc9716e0f1353791f5908f94d6e726 499 func Uncompress(src string, dst string) error { 500 file, err := os.OpenFile(src, os.O_RDWR|os.O_CREATE, 0666) 501 defer file.Close() 502 if err != nil { 503 return err 504 } 505 // ungzip 506 zr, err := gzip.NewReader(file) 507 if err != nil { 508 return err 509 } 510 // untar 511 tr := tar.NewReader(zr) 512 513 // uncompress each element 514 for { 515 header, err := tr.Next() 516 if err == io.EOF { 517 break // End of archive 518 } 519 if err != nil { 520 return err 521 } 522 target := header.Name 523 524 // validate name against path traversal 525 if !validRelPath(header.Name) { 526 return fmt.Errorf("tar contained invalid name error %q\n", target) 527 } 528 529 // add dst + re-format slashes according to system 530 target = filepath.Join(dst, header.Name) 531 // if no join is needed, replace with ToSlash: 532 // target = filepath.ToSlash(header.Name) 533 534 // check the type 535 switch header.Typeflag { 536 537 // if its a dir and it doesn't exist create it (with 0755 permission) 538 case tar.TypeDir: 539 if _, err := os.Stat(target); err != nil { 540 // @todo think about this: 541 // if we don't nuke the folder, we might end up with files from 542 // the previous decompress. 543 if err := os.MkdirAll(target, 0755); err != nil { 544 return err 545 } 546 } 547 // if it's a file create it (with same permission) 548 case tar.TypeReg: 549 // the truncating is probably unnecessary due to the `RemoveAll` of folders 550 // above 551 fileToWrite, err := os.OpenFile(target, os.O_TRUNC|os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) 552 if err != nil { 553 return err 554 } 555 // copy over contents 556 if _, err := io.Copy(fileToWrite, tr); err != nil { 557 return err 558 } 559 // manually close here after each file operation; defering would cause each file close 560 // to wait until all operations have completed. 561 fileToWrite.Close() 562 } 563 } 564 return nil 565 } 566 567 // check for path traversal and correct forward slashes 568 func validRelPath(p string) bool { 569 if p == "" || strings.Contains(p, `\`) || strings.HasPrefix(p, "/") || strings.Contains(p, "../") { 570 return false 571 } 572 return true 573 } 574 575 // taken from https://stackoverflow.com/questions/20357223/easy-way-to-unzip-file-with-golang 576 func unzip(src, dest string, skipTopFolder bool) error { 577 r, err := zip.OpenReader(src) 578 if err != nil { 579 return err 580 } 581 defer func() { 582 r.Close() 583 }() 584 585 os.MkdirAll(dest, 0755) 586 587 // Closure to address file descriptors issue with all the deferred .Close() methods 588 extractAndWriteFile := func(f *zip.File) error { 589 rc, err := f.Open() 590 if err != nil { 591 return err 592 } 593 defer func() { 594 rc.Close() 595 }() 596 if skipTopFolder { 597 f.Name = strings.Join(strings.Split(f.Name, string(filepath.Separator))[1:], string(filepath.Separator)) 598 } 599 // zip slip https://snyk.io/research/zip-slip-vulnerability 600 destpath, err := zipSafeFilePath(dest, f.Name) 601 if err != nil { 602 return err 603 } 604 path := destpath 605 if f.FileInfo().IsDir() { 606 os.MkdirAll(path, f.Mode()) 607 } else { 608 os.MkdirAll(filepath.Dir(path), f.Mode()) 609 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 610 if err != nil { 611 return err 612 } 613 defer func() { 614 f.Close() 615 }() 616 617 _, err = io.Copy(f, rc) 618 if err != nil { 619 return err 620 } 621 } 622 return nil 623 } 624 625 for _, f := range r.File { 626 err := extractAndWriteFile(f) 627 if err != nil { 628 return err 629 } 630 } 631 632 return nil 633 } 634 635 // zipSafeFilePath checks whether the file path is safe to use or is a zip slip attack https://snyk.io/research/zip-slip-vulnerability 636 func zipSafeFilePath(destination, filePath string) (string, error) { 637 if len(filePath) == 0 { 638 return filepath.Join(destination, filePath), nil 639 } 640 destination, _ = filepath.Abs(destination) //explicit the destination folder to prevent that 'string.HasPrefix' check can be 'bypassed' when no destination folder is supplied in input 641 destpath := filepath.Join(destination, filePath) 642 if !strings.HasPrefix(destpath, filepath.Clean(destination)+string(os.PathSeparator)) { 643 return "", fmt.Errorf("%s: illegal file path", filePath) 644 } 645 return destpath, nil 646 }