github.com/sirkon/goproxy@v1.4.8/internal/modfetch/codehost/git.go (about) 1 // Copyright 2018 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package codehost 6 7 import ( 8 "bytes" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "os" 13 "path/filepath" 14 "sort" 15 "strconv" 16 "strings" 17 "sync" 18 "time" 19 20 "github.com/sirkon/goproxy/internal/par" 21 ) 22 23 // GitRepo returns the code repository at the given Git remote reference. 24 func GitRepo(remote string) (Repo, error) { 25 return newGitRepoCached(remote, false) 26 } 27 28 // LocalGitRepo is like Repo but accepts both Git remote references 29 // and paths to repositories on the local file system. 30 func LocalGitRepo(remote string) (Repo, error) { 31 return newGitRepoCached(remote, true) 32 } 33 34 const gitWorkDirType = "git2" 35 36 var gitRepoCache par.Cache 37 38 func newGitRepoCached(remote string, localOK bool) (Repo, error) { 39 type key struct { 40 remote string 41 localOK bool 42 } 43 type cached struct { 44 repo Repo 45 err error 46 } 47 48 c := gitRepoCache.Do(key{remote, localOK}, func() interface{} { 49 repo, err := newGitRepo(remote, localOK) 50 return cached{repo, err} 51 }).(cached) 52 53 return c.repo, c.err 54 } 55 56 func newGitRepo(remote string, localOK bool) (Repo, error) { 57 r := &gitRepo{remote: remote} 58 if strings.Contains(remote, "://") { 59 // This is a remote path. 60 dir, err := WorkDir(gitWorkDirType, r.remote) 61 if err != nil { 62 return nil, err 63 } 64 r.dir = dir 65 if _, err := os.Stat(filepath.Join(dir, "objects")); err != nil { 66 if _, err := Run(dir, "git", "init", "--bare"); err != nil { 67 os.RemoveAll(dir) 68 return nil, err 69 } 70 // We could just say git fetch https://whatever later, 71 // but this lets us say git fetch origin instead, which 72 // is a little nicer. More importantly, using a named remote 73 // avoids a problem with Git LFS. See golang.org/issue/25605. 74 if _, err := Run(dir, "git", "remote", "add", "origin", r.remote); err != nil { 75 os.RemoveAll(dir) 76 return nil, err 77 } 78 r.remote = "origin" 79 } 80 } else { 81 // Local path. 82 // Disallow colon (not in ://) because sometimes 83 // that's rcp-style host:path syntax and sometimes it's not (c:\work). 84 // The go command has always insisted on URL syntax for ssh. 85 if strings.Contains(remote, ":") { 86 return nil, fmt.Errorf("git remote cannot use host:path syntax") 87 } 88 if !localOK { 89 return nil, fmt.Errorf("git remote must not be local directory") 90 } 91 r.local = true 92 info, err := os.Stat(remote) 93 if err != nil { 94 return nil, err 95 } 96 if !info.IsDir() { 97 return nil, fmt.Errorf("%s exists but is not a directory", remote) 98 } 99 r.dir = remote 100 } 101 return r, nil 102 } 103 104 type gitRepo struct { 105 remote string 106 local bool 107 dir string 108 109 mu sync.Mutex // protects fetchLevel, some git repo state 110 fetchLevel int 111 112 statCache par.Cache 113 114 refsOnce sync.Once 115 refs map[string]string 116 refsErr error 117 118 localTagsOnce sync.Once 119 localTags map[string]bool 120 } 121 122 const ( 123 // How much have we fetched into the git repo (in this process)? 124 fetchNone = iota // nothing yet 125 fetchSome // shallow fetches of individual hashes 126 fetchAll // "fetch -t origin": get all remote branches and tags 127 ) 128 129 // loadLocalTags loads tag references from the local git cache 130 // into the map r.localTags. 131 // Should only be called as r.localTagsOnce.Do(r.loadLocalTags). 132 func (r *gitRepo) loadLocalTags() { 133 // The git protocol sends all known refs and ls-remote filters them on the client side, 134 // so we might as well record both heads and tags in one shot. 135 // Most of the time we only care about tags but sometimes we care about heads too. 136 out, err := Run(r.dir, "git", "tag", "-l") 137 if err != nil { 138 return 139 } 140 141 r.localTags = make(map[string]bool) 142 for _, line := range strings.Split(string(out), "\n") { 143 if line != "" { 144 r.localTags[line] = true 145 } 146 } 147 } 148 149 // loadRefs loads heads and tags references from the remote into the map r.refs. 150 // Should only be called as r.refsOnce.Do(r.loadRefs). 151 func (r *gitRepo) loadRefs() { 152 // The git protocol sends all known refs and ls-remote filters them on the client side, 153 // so we might as well record both heads and tags in one shot. 154 // Most of the time we only care about tags but sometimes we care about heads too. 155 out, err := Run(r.dir, "git", "ls-remote", "-q", r.remote) 156 if err != nil { 157 r.refsErr = err 158 return 159 } 160 161 r.refs = make(map[string]string) 162 for _, line := range strings.Split(string(out), "\n") { 163 f := strings.Fields(line) 164 if len(f) != 2 { 165 continue 166 } 167 if f[1] == "HEAD" || strings.HasPrefix(f[1], "refs/heads/") || strings.HasPrefix(f[1], "refs/tags/") { 168 r.refs[f[1]] = f[0] 169 } 170 } 171 for ref, hash := range r.refs { 172 if strings.HasSuffix(ref, "^{}") { // record unwrapped annotated tag as value of tag 173 r.refs[strings.TrimSuffix(ref, "^{}")] = hash 174 delete(r.refs, ref) 175 } 176 } 177 } 178 179 func (r *gitRepo) Tags(prefix string) ([]string, error) { 180 r.refsOnce.Do(r.loadRefs) 181 if r.refsErr != nil { 182 return nil, r.refsErr 183 } 184 185 tags := []string{} 186 for ref := range r.refs { 187 if !strings.HasPrefix(ref, "refs/tags/") { 188 continue 189 } 190 tag := ref[len("refs/tags/"):] 191 if !strings.HasPrefix(tag, prefix) { 192 continue 193 } 194 tags = append(tags, tag) 195 } 196 sort.Strings(tags) 197 return tags, nil 198 } 199 200 func (r *gitRepo) Latest() (*RevInfo, error) { 201 r.refsOnce.Do(r.loadRefs) 202 if r.refsErr != nil { 203 return nil, r.refsErr 204 } 205 if r.refs["HEAD"] == "" { 206 return nil, fmt.Errorf("no commits") 207 } 208 return r.Stat(r.refs["HEAD"]) 209 } 210 211 // findRef finds some ref name for the given hash, 212 // for use when the server requires giving a ref instead of a hash. 213 // There may be multiple ref names for a given hash, 214 // in which case this returns some name - it doesn't matter which. 215 func (r *gitRepo) findRef(hash string) (ref string, ok bool) { 216 r.refsOnce.Do(r.loadRefs) 217 for ref, h := range r.refs { 218 if h == hash { 219 return ref, true 220 } 221 } 222 return "", false 223 } 224 225 func unshallow(gitDir string) []string { 226 if _, err := os.Stat(filepath.Join(gitDir, "shallow")); err == nil { 227 return []string{"--unshallow"} 228 } 229 return []string{} 230 } 231 232 // minHashDigits is the minimum number of digits to require 233 // before accepting a hex digit sequence as potentially identifying 234 // a specific commit in a git repo. (Of course, users can always 235 // specify more digits, and many will paste in all 40 digits, 236 // but many of git's commands default to printing short hashes 237 // as 7 digits.) 238 const minHashDigits = 7 239 240 // stat stats the given rev in the local repository, 241 // or else it fetches more info from the remote repository and tries again. 242 func (r *gitRepo) stat(rev string) (*RevInfo, error) { 243 if r.local { 244 return r.statLocal(rev, rev) 245 } 246 247 // Fast path: maybe rev is a hash we already have locally. 248 didStatLocal := false 249 if len(rev) >= minHashDigits && len(rev) <= 40 && AllHex(rev) { 250 if info, err := r.statLocal(rev, rev); err == nil { 251 return info, nil 252 } 253 didStatLocal = true 254 } 255 256 // Maybe rev is a tag we already have locally. 257 // (Note that we're excluding branches, which can be stale.) 258 r.localTagsOnce.Do(r.loadLocalTags) 259 if r.localTags[rev] { 260 return r.statLocal(rev, "refs/tags/"+rev) 261 } 262 263 // Maybe rev is the name of a tag or branch on the remote server. 264 // Or maybe it's the prefix of a hash of a named ref. 265 // Try to resolve to both a ref (git name) and full (40-hex-digit) commit hash. 266 r.refsOnce.Do(r.loadRefs) 267 var ref, hash string 268 if r.refs["refs/tags/"+rev] != "" { 269 ref = "refs/tags/" + rev 270 hash = r.refs[ref] 271 // Keep rev as is: tags are assumed not to change meaning. 272 } else if r.refs["refs/heads/"+rev] != "" { 273 ref = "refs/heads/" + rev 274 hash = r.refs[ref] 275 rev = hash // Replace rev, because meaning of refs/heads/foo can change. 276 } else if rev == "HEAD" && r.refs["HEAD"] != "" { 277 ref = "HEAD" 278 hash = r.refs[ref] 279 rev = hash // Replace rev, because meaning of HEAD can change. 280 } else if len(rev) >= minHashDigits && len(rev) <= 40 && AllHex(rev) { 281 // At the least, we have a hash prefix we can look up after the fetch below. 282 // Maybe we can map it to a full hash using the known refs. 283 prefix := rev 284 // Check whether rev is prefix of known ref hash. 285 for k, h := range r.refs { 286 if strings.HasPrefix(h, prefix) { 287 if hash != "" && hash != h { 288 // Hash is an ambiguous hash prefix. 289 // More information will not change that. 290 return nil, fmt.Errorf("ambiguous revision %s", rev) 291 } 292 if ref == "" || ref > k { // Break ties deterministically when multiple refs point at same hash. 293 ref = k 294 } 295 rev = h 296 hash = h 297 } 298 } 299 if hash == "" && len(rev) == 40 { // Didn't find a ref, but rev is a full hash. 300 hash = rev 301 } 302 } else { 303 return nil, fmt.Errorf("unknown revision %s", rev) 304 } 305 306 // Protect r.fetchLevel and the "fetch more and more" sequence. 307 // TODO(rsc): Add LockDir and use it for protecting that 308 // sequence, so that multiple processes don't collide in their 309 // git commands. 310 r.mu.Lock() 311 defer r.mu.Unlock() 312 313 // Perhaps r.localTags did not have the ref when we loaded local tags, 314 // but we've since done fetches that pulled down the hash we need 315 // (or already have the hash we need, just without its tag). 316 // Either way, try a local stat before falling back to network I/O. 317 if !didStatLocal { 318 if info, err := r.statLocal(rev, hash); err == nil { 319 if strings.HasPrefix(ref, "refs/tags/") { 320 // Make sure tag exists, so it will be in localTags next time the go command is run. 321 Run(r.dir, "git", "tag", strings.TrimPrefix(ref, "refs/tags/"), hash) 322 } 323 return info, nil 324 } 325 } 326 327 // If we know a specific commit we need, fetch it. 328 if r.fetchLevel <= fetchSome && hash != "" && !r.local { 329 r.fetchLevel = fetchSome 330 var refspec string 331 if ref != "" && ref != "HEAD" { 332 // If we do know the ref name, save the mapping locally 333 // so that (if it is a tag) it can show up in localTags 334 // on a future call. Also, some servers refuse to allow 335 // full hashes in ref specs, so prefer a ref name if known. 336 refspec = ref + ":" + ref 337 } else { 338 // Fetch the hash but give it a local name (refs/dummy), 339 // because that triggers the fetch behavior of creating any 340 // other known remote tags for the hash. We never use 341 // refs/dummy (it's not refs/tags/dummy) and it will be 342 // overwritten in the next command, and that's fine. 343 ref = hash 344 refspec = hash + ":refs/dummy" 345 } 346 _, err := Run(r.dir, "git", "fetch", "-f", "--depth=1", r.remote, refspec) 347 if err == nil { 348 return r.statLocal(rev, ref) 349 } 350 // Don't try to be smart about parsing the error. 351 // It's too complex and varies too much by git version. 352 // No matter what went wrong, fall back to a complete fetch. 353 } 354 355 // Last resort. 356 // Fetch all heads and tags and hope the hash we want is in the history. 357 if r.fetchLevel < fetchAll { 358 // TODO(bcmills): should we wait to upgrade fetchLevel until after we check 359 // err? If there is a temporary server error, we want subsequent fetches to 360 // try again instead of proceeding with an incomplete repo. 361 r.fetchLevel = fetchAll 362 if err := r.fetchUnshallow("refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"); err != nil { 363 return nil, err 364 } 365 } 366 367 return r.statLocal(rev, rev) 368 } 369 370 func (r *gitRepo) fetchUnshallow(refSpecs ...string) error { 371 // To work around a protocol version 2 bug that breaks --unshallow, 372 // add -c protocol.version=0. 373 // TODO(rsc): The bug is believed to be server-side, meaning only 374 // on Google's Git servers. Once the servers are fixed, drop the 375 // protocol.version=0. See Google-internal bug b/110495752. 376 var protoFlag []string 377 unshallowFlag := unshallow(r.dir) 378 if len(unshallowFlag) > 0 { 379 protoFlag = []string{"-c", "protocol.version=0"} 380 } 381 _, err := Run(r.dir, "git", protoFlag, "fetch", unshallowFlag, "-f", r.remote, refSpecs) 382 return err 383 } 384 385 // statLocal returns a RevInfo describing rev in the local git repository. 386 // It uses version as info.Version. 387 func (r *gitRepo) statLocal(version, rev string) (*RevInfo, error) { 388 out, err := Run(r.dir, "git", "-c", "log.showsignature=false", "log", "-n1", "--format=format:%H %ct %D", rev) 389 if err != nil { 390 return nil, fmt.Errorf("unknown revision %s", rev) 391 } 392 f := strings.Fields(string(out)) 393 if len(f) < 2 { 394 return nil, fmt.Errorf("unexpected response from git log: %q", out) 395 } 396 hash := f[0] 397 if strings.HasPrefix(hash, version) { 398 version = hash // extend to full hash 399 } 400 t, err := strconv.ParseInt(f[1], 10, 64) 401 if err != nil { 402 return nil, fmt.Errorf("invalid time from git log: %q", out) 403 } 404 405 info := &RevInfo{ 406 Name: hash, 407 Short: ShortenSHA1(hash), 408 Time: time.Unix(t, 0).UTC(), 409 Version: hash, 410 } 411 412 // Add tags. Output looks like: 413 // ede458df7cd0fdca520df19a33158086a8a68e81 1523994202 HEAD -> master, tag: v1.2.4-annotated, tag: v1.2.3, origin/master, origin/HEAD 414 for i := 2; i < len(f); i++ { 415 if f[i] == "tag:" { 416 i++ 417 if i < len(f) { 418 info.Tags = append(info.Tags, strings.TrimSuffix(f[i], ",")) 419 } 420 } 421 } 422 sort.Strings(info.Tags) 423 424 // Used hash as info.Version above. 425 // Use caller's suggested version if it appears in the tag list 426 // (filters out branch names, HEAD). 427 for _, tag := range info.Tags { 428 if version == tag { 429 info.Version = version 430 } 431 } 432 433 return info, nil 434 } 435 436 func (r *gitRepo) Stat(rev string) (*RevInfo, error) { 437 if rev == "latest" { 438 return r.Latest() 439 } 440 type cached struct { 441 info *RevInfo 442 err error 443 } 444 c := r.statCache.Do(rev, func() interface{} { 445 info, err := r.stat(rev) 446 return cached{info, err} 447 }).(cached) 448 return c.info, c.err 449 } 450 451 func (r *gitRepo) ReadFile(rev, file string, maxSize int64) ([]byte, error) { 452 // TODO: Could use git cat-file --batch. 453 info, err := r.Stat(rev) // download rev into local git repo 454 if err != nil { 455 return nil, err 456 } 457 out, err := Run(r.dir, "git", "cat-file", "blob", info.Name+":"+file) 458 if err != nil { 459 return nil, os.ErrNotExist 460 } 461 return out, nil 462 } 463 464 func (r *gitRepo) ReadFileRevs(revs []string, file string, maxSize int64) (map[string]*FileRev, error) { 465 // Create space to hold results. 466 files := make(map[string]*FileRev) 467 for _, rev := range revs { 468 f := &FileRev{Rev: rev} 469 files[rev] = f 470 } 471 472 // Collect locally-known revs. 473 need, err := r.readFileRevs(revs, file, files) 474 if err != nil { 475 return nil, err 476 } 477 if len(need) == 0 { 478 return files, nil 479 } 480 481 // Build list of known remote refs that might help. 482 var redo []string 483 r.refsOnce.Do(r.loadRefs) 484 if r.refsErr != nil { 485 return nil, r.refsErr 486 } 487 for _, tag := range need { 488 if r.refs["refs/tags/"+tag] != "" { 489 redo = append(redo, tag) 490 } 491 } 492 if len(redo) == 0 { 493 return files, nil 494 } 495 496 // Protect r.fetchLevel and the "fetch more and more" sequence. 497 // See stat method above. 498 r.mu.Lock() 499 defer r.mu.Unlock() 500 501 var refs []string 502 var protoFlag []string 503 var unshallowFlag []string 504 for _, tag := range redo { 505 refs = append(refs, "refs/tags/"+tag+":refs/tags/"+tag) 506 } 507 if len(refs) > 1 { 508 unshallowFlag = unshallow(r.dir) 509 if len(unshallowFlag) > 0 { 510 // To work around a protocol version 2 bug that breaks --unshallow, 511 // add -c protocol.version=0. 512 // TODO(rsc): The bug is believed to be server-side, meaning only 513 // on Google's Git servers. Once the servers are fixed, drop the 514 // protocol.version=0. See Google-internal bug b/110495752. 515 protoFlag = []string{"-c", "protocol.version=0"} 516 } 517 } 518 if _, err := Run(r.dir, "git", protoFlag, "fetch", unshallowFlag, "-f", r.remote, refs); err != nil { 519 return nil, err 520 } 521 522 // TODO(bcmills): after the 1.11 freeze, replace the block above with: 523 // if r.fetchLevel <= fetchSome { 524 // r.fetchLevel = fetchSome 525 // var refs []string 526 // for _, tag := range redo { 527 // refs = append(refs, "refs/tags/"+tag+":refs/tags/"+tag) 528 // } 529 // if _, err := Run(r.dir, "git", "fetch", "--update-shallow", "-f", r.remote, refs); err != nil { 530 // return nil, err 531 // } 532 // } 533 534 if _, err := r.readFileRevs(redo, file, files); err != nil { 535 return nil, err 536 } 537 538 return files, nil 539 } 540 541 func (r *gitRepo) readFileRevs(tags []string, file string, fileMap map[string]*FileRev) (missing []string, err error) { 542 var stdin bytes.Buffer 543 for _, tag := range tags { 544 fmt.Fprintf(&stdin, "refs/tags/%s\n", tag) 545 fmt.Fprintf(&stdin, "refs/tags/%s:%s\n", tag, file) 546 } 547 548 data, err := RunWithStdin(r.dir, &stdin, "git", "cat-file", "--batch") 549 if err != nil { 550 return nil, err 551 } 552 553 next := func() (typ string, body []byte, ok bool) { 554 var line string 555 i := bytes.IndexByte(data, '\n') 556 if i < 0 { 557 return "", nil, false 558 } 559 line, data = string(bytes.TrimSpace(data[:i])), data[i+1:] 560 if strings.HasSuffix(line, " missing") { 561 return "missing", nil, true 562 } 563 f := strings.Fields(line) 564 if len(f) != 3 { 565 return "", nil, false 566 } 567 n, err := strconv.Atoi(f[2]) 568 if err != nil || n > len(data) { 569 return "", nil, false 570 } 571 body, data = data[:n], data[n:] 572 if len(data) > 0 && data[0] == '\r' { 573 data = data[1:] 574 } 575 if len(data) > 0 && data[0] == '\n' { 576 data = data[1:] 577 } 578 return f[1], body, true 579 } 580 581 badGit := func() ([]string, error) { 582 return nil, fmt.Errorf("malformed output from git cat-file --batch") 583 } 584 585 for _, tag := range tags { 586 commitType, _, ok := next() 587 if !ok { 588 return badGit() 589 } 590 fileType, fileData, ok := next() 591 if !ok { 592 return badGit() 593 } 594 f := fileMap[tag] 595 f.Data = nil 596 f.Err = nil 597 switch commitType { 598 default: 599 f.Err = fmt.Errorf("unexpected non-commit type %q for rev %s", commitType, tag) 600 601 case "missing": 602 // Note: f.Err must not satisfy os.IsNotExist. That's reserved for the file not existing in a valid commit. 603 f.Err = fmt.Errorf("no such rev %s", tag) 604 missing = append(missing, tag) 605 606 case "tag", "commit": 607 switch fileType { 608 default: 609 f.Err = &os.PathError{Path: tag + ":" + file, Op: "read", Err: fmt.Errorf("unexpected non-blob type %q", fileType)} 610 case "missing": 611 f.Err = &os.PathError{Path: tag + ":" + file, Op: "read", Err: os.ErrNotExist} 612 case "blob": 613 f.Data = fileData 614 } 615 } 616 } 617 if len(bytes.TrimSpace(data)) != 0 { 618 return badGit() 619 } 620 621 return missing, nil 622 } 623 624 func (r *gitRepo) RecentTag(rev, prefix string) (tag string, err error) { 625 info, err := r.Stat(rev) 626 if err != nil { 627 return "", err 628 } 629 rev = info.Name // expand hash prefixes 630 631 // describe sets tag and err using 'git describe' and reports whether the 632 // result is definitive. 633 describe := func() (definitive bool) { 634 var out []byte 635 out, err = Run(r.dir, "git", "describe", "--first-parent", "--always", "--abbrev=0", "--match", prefix+"v[0-9]*.[0-9]*.[0-9]*", "--tags", rev) 636 if err != nil { 637 return true // Because we use "--always", describe should never fail. 638 } 639 640 tag = string(bytes.TrimSpace(out)) 641 return tag != "" && !AllHex(tag) 642 } 643 644 if describe() { 645 return tag, err 646 } 647 648 // Git didn't find a version tag preceding the requested rev. 649 // See whether any plausible tag exists. 650 tags, err := r.Tags(prefix + "v") 651 if err != nil { 652 return "", err 653 } 654 if len(tags) == 0 { 655 return "", nil 656 } 657 658 // There are plausible tags, but we don't know if rev is a descendent of any of them. 659 // Fetch the history to find out. 660 661 r.mu.Lock() 662 defer r.mu.Unlock() 663 664 if r.fetchLevel < fetchAll { 665 // Fetch all heads and tags and see if that gives us enough history. 666 if err := r.fetchUnshallow("refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"); err != nil { 667 return "", err 668 } 669 r.fetchLevel = fetchAll 670 } 671 672 // If we've reached this point, we have all of the commits that are reachable 673 // from all heads and tags. 674 // 675 // The only refs we should be missing are those that are no longer reachable 676 // (or never were reachable) from any branch or tag, including the master 677 // branch, and we don't want to resolve them anyway (they're probably 678 // unreachable for a reason). 679 // 680 // Try one last time in case some other goroutine fetched rev while we were 681 // waiting on r.mu. 682 describe() 683 return tag, err 684 } 685 686 func (r *gitRepo) ReadZip(rev, subdir string, maxSize int64) (zip io.ReadCloser, actualSubdir string, err error) { 687 // TODO: Use maxSize or drop it. 688 args := []string{} 689 if subdir != "" { 690 args = append(args, "--", subdir) 691 } 692 info, err := r.Stat(rev) // download rev into local git repo 693 if err != nil { 694 return nil, "", err 695 } 696 697 // Incredibly, git produces different archives depending on whether 698 // it is running on a Windows system or not, in an attempt to normalize 699 // text file line endings. Setting -c core.autocrlf=input means only 700 // translate files on the way into the repo, not on the way out (archive). 701 // The -c core.eol=lf should be unnecessary but set it anyway. 702 archive, err := Run(r.dir, "git", "-c", "core.autocrlf=input", "-c", "core.eol=lf", "archive", "--format=zip", "--prefix=prefix/", info.Name, args) 703 if err != nil { 704 if bytes.Contains(err.(*RunError).Stderr, []byte("did not match any files")) { 705 return nil, "", os.ErrNotExist 706 } 707 return nil, "", err 708 } 709 710 return ioutil.NopCloser(bytes.NewReader(archive)), "", nil 711 }