github.com/go-asm/go@v1.21.1-0.20240213172139-40c5ead50c48/cmd/go/modfetch/codehost/vcs.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 "context" 9 "errors" 10 "fmt" 11 "io" 12 "io/fs" 13 "os" 14 "path/filepath" 15 "sort" 16 "strconv" 17 "strings" 18 "sync" 19 "time" 20 21 "github.com/go-asm/go/lazyregexp" 22 23 "github.com/go-asm/go/cmd/go/base" 24 "github.com/go-asm/go/cmd/go/lockedfile" 25 "github.com/go-asm/go/cmd/go/par" 26 "github.com/go-asm/go/cmd/go/str" 27 ) 28 29 // A VCSError indicates an error using a version control system. 30 // The implication of a VCSError is that we know definitively where 31 // to get the code, but we can't access it due to the error. 32 // The caller should report this error instead of continuing to probe 33 // other possible module paths. 34 // 35 // TODO(golang.org/issue/31730): See if we can invert this. (Return a 36 // distinguished error for “repo not found” and treat everything else 37 // as terminal.) 38 type VCSError struct { 39 Err error 40 } 41 42 func (e *VCSError) Error() string { return e.Err.Error() } 43 44 func (e *VCSError) Unwrap() error { return e.Err } 45 46 func vcsErrorf(format string, a ...any) error { 47 return &VCSError{Err: fmt.Errorf(format, a...)} 48 } 49 50 type vcsCacheKey struct { 51 vcs string 52 remote string 53 } 54 55 func NewRepo(ctx context.Context, vcs, remote string) (Repo, error) { 56 return vcsRepoCache.Do(vcsCacheKey{vcs, remote}, func() (Repo, error) { 57 repo, err := newVCSRepo(ctx, vcs, remote) 58 if err != nil { 59 return nil, &VCSError{err} 60 } 61 return repo, nil 62 }) 63 } 64 65 var vcsRepoCache par.ErrCache[vcsCacheKey, Repo] 66 67 type vcsRepo struct { 68 mu lockedfile.Mutex // protects all commands, so we don't have to decide which are safe on a per-VCS basis 69 70 remote string 71 cmd *vcsCmd 72 dir string 73 74 tagsOnce sync.Once 75 tags map[string]bool 76 77 branchesOnce sync.Once 78 branches map[string]bool 79 80 fetchOnce sync.Once 81 fetchErr error 82 } 83 84 func newVCSRepo(ctx context.Context, vcs, remote string) (Repo, error) { 85 if vcs == "git" { 86 return newGitRepo(ctx, remote, false) 87 } 88 cmd := vcsCmds[vcs] 89 if cmd == nil { 90 return nil, fmt.Errorf("unknown vcs: %s %s", vcs, remote) 91 } 92 if !strings.Contains(remote, "://") { 93 return nil, fmt.Errorf("invalid vcs remote: %s %s", vcs, remote) 94 } 95 96 r := &vcsRepo{remote: remote, cmd: cmd} 97 var err error 98 r.dir, r.mu.Path, err = WorkDir(ctx, vcsWorkDirType+vcs, r.remote) 99 if err != nil { 100 return nil, err 101 } 102 103 if cmd.init == nil { 104 return r, nil 105 } 106 107 unlock, err := r.mu.Lock() 108 if err != nil { 109 return nil, err 110 } 111 defer unlock() 112 113 if _, err := os.Stat(filepath.Join(r.dir, "."+vcs)); err != nil { 114 release, err := base.AcquireNet() 115 if err != nil { 116 return nil, err 117 } 118 _, err = Run(ctx, r.dir, cmd.init(r.remote)) 119 release() 120 121 if err != nil { 122 os.RemoveAll(r.dir) 123 return nil, err 124 } 125 } 126 return r, nil 127 } 128 129 const vcsWorkDirType = "vcs1." 130 131 type vcsCmd struct { 132 vcs string // vcs name "hg" 133 init func(remote string) []string // cmd to init repo to track remote 134 tags func(remote string) []string // cmd to list local tags 135 tagRE *lazyregexp.Regexp // regexp to extract tag names from output of tags cmd 136 branches func(remote string) []string // cmd to list local branches 137 branchRE *lazyregexp.Regexp // regexp to extract branch names from output of tags cmd 138 badLocalRevRE *lazyregexp.Regexp // regexp of names that must not be served out of local cache without doing fetch first 139 statLocal func(rev, remote string) []string // cmd to stat local rev 140 parseStat func(rev, out string) (*RevInfo, error) // cmd to parse output of statLocal 141 fetch []string // cmd to fetch everything from remote 142 latest string // name of latest commit on remote (tip, HEAD, etc) 143 readFile func(rev, file, remote string) []string // cmd to read rev's file 144 readZip func(rev, subdir, remote, target string) []string // cmd to read rev's subdir as zip file 145 doReadZip func(ctx context.Context, dst io.Writer, workDir, rev, subdir, remote string) error // arbitrary function to read rev's subdir as zip file 146 } 147 148 var re = lazyregexp.New 149 150 var vcsCmds = map[string]*vcsCmd{ 151 "hg": { 152 vcs: "hg", 153 init: func(remote string) []string { 154 return []string{"hg", "clone", "-U", "--", remote, "."} 155 }, 156 tags: func(remote string) []string { 157 return []string{"hg", "tags", "-q"} 158 }, 159 tagRE: re(`(?m)^[^\n]+$`), 160 branches: func(remote string) []string { 161 return []string{"hg", "branches", "-c", "-q"} 162 }, 163 branchRE: re(`(?m)^[^\n]+$`), 164 badLocalRevRE: re(`(?m)^(tip)$`), 165 statLocal: func(rev, remote string) []string { 166 return []string{"hg", "log", "-l1", "-r", rev, "--template", "{node} {date|hgdate} {tags}"} 167 }, 168 parseStat: hgParseStat, 169 fetch: []string{"hg", "pull", "-f"}, 170 latest: "tip", 171 readFile: func(rev, file, remote string) []string { 172 return []string{"hg", "cat", "-r", rev, file} 173 }, 174 readZip: func(rev, subdir, remote, target string) []string { 175 pattern := []string{} 176 if subdir != "" { 177 pattern = []string{"-I", subdir + "/**"} 178 } 179 return str.StringList("hg", "archive", "-t", "zip", "--no-decode", "-r", rev, "--prefix=prefix/", pattern, "--", target) 180 }, 181 }, 182 183 "svn": { 184 vcs: "svn", 185 init: nil, // no local checkout 186 tags: func(remote string) []string { 187 return []string{"svn", "list", "--", strings.TrimSuffix(remote, "/trunk") + "/tags"} 188 }, 189 tagRE: re(`(?m)^(.*?)/?$`), 190 statLocal: func(rev, remote string) []string { 191 suffix := "@" + rev 192 if rev == "latest" { 193 suffix = "" 194 } 195 return []string{"svn", "log", "-l1", "--xml", "--", remote + suffix} 196 }, 197 parseStat: svnParseStat, 198 latest: "latest", 199 readFile: func(rev, file, remote string) []string { 200 return []string{"svn", "cat", "--", remote + "/" + file + "@" + rev} 201 }, 202 doReadZip: svnReadZip, 203 }, 204 205 "bzr": { 206 vcs: "bzr", 207 init: func(remote string) []string { 208 return []string{"bzr", "branch", "--use-existing-dir", "--", remote, "."} 209 }, 210 fetch: []string{ 211 "bzr", "pull", "--overwrite-tags", 212 }, 213 tags: func(remote string) []string { 214 return []string{"bzr", "tags"} 215 }, 216 tagRE: re(`(?m)^\S+`), 217 badLocalRevRE: re(`^revno:-`), 218 statLocal: func(rev, remote string) []string { 219 return []string{"bzr", "log", "-l1", "--long", "--show-ids", "-r", rev} 220 }, 221 parseStat: bzrParseStat, 222 latest: "revno:-1", 223 readFile: func(rev, file, remote string) []string { 224 return []string{"bzr", "cat", "-r", rev, file} 225 }, 226 readZip: func(rev, subdir, remote, target string) []string { 227 extra := []string{} 228 if subdir != "" { 229 extra = []string{"./" + subdir} 230 } 231 return str.StringList("bzr", "export", "--format=zip", "-r", rev, "--root=prefix/", "--", target, extra) 232 }, 233 }, 234 235 "fossil": { 236 vcs: "fossil", 237 init: func(remote string) []string { 238 return []string{"fossil", "clone", "--", remote, ".fossil"} 239 }, 240 fetch: []string{"fossil", "pull", "-R", ".fossil"}, 241 tags: func(remote string) []string { 242 return []string{"fossil", "tag", "-R", ".fossil", "list"} 243 }, 244 tagRE: re(`XXXTODO`), 245 statLocal: func(rev, remote string) []string { 246 return []string{"fossil", "info", "-R", ".fossil", rev} 247 }, 248 parseStat: fossilParseStat, 249 latest: "trunk", 250 readFile: func(rev, file, remote string) []string { 251 return []string{"fossil", "cat", "-R", ".fossil", "-r", rev, file} 252 }, 253 readZip: func(rev, subdir, remote, target string) []string { 254 extra := []string{} 255 if subdir != "" && !strings.ContainsAny(subdir, "*?[],") { 256 extra = []string{"--include", subdir} 257 } 258 // Note that vcsRepo.ReadZip below rewrites this command 259 // to run in a different directory, to work around a fossil bug. 260 return str.StringList("fossil", "zip", "-R", ".fossil", "--name", "prefix", extra, "--", rev, target) 261 }, 262 }, 263 } 264 265 func (r *vcsRepo) loadTags(ctx context.Context) { 266 out, err := Run(ctx, r.dir, r.cmd.tags(r.remote)) 267 if err != nil { 268 return 269 } 270 271 // Run tag-listing command and extract tags. 272 r.tags = make(map[string]bool) 273 for _, tag := range r.cmd.tagRE.FindAllString(string(out), -1) { 274 if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(tag) { 275 continue 276 } 277 r.tags[tag] = true 278 } 279 } 280 281 func (r *vcsRepo) loadBranches(ctx context.Context) { 282 if r.cmd.branches == nil { 283 return 284 } 285 286 out, err := Run(ctx, r.dir, r.cmd.branches(r.remote)) 287 if err != nil { 288 return 289 } 290 291 r.branches = make(map[string]bool) 292 for _, branch := range r.cmd.branchRE.FindAllString(string(out), -1) { 293 if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(branch) { 294 continue 295 } 296 r.branches[branch] = true 297 } 298 } 299 300 func (r *vcsRepo) CheckReuse(ctx context.Context, old *Origin, subdir string) error { 301 return fmt.Errorf("vcs %s: CheckReuse: %w", r.cmd.vcs, errors.ErrUnsupported) 302 } 303 304 func (r *vcsRepo) Tags(ctx context.Context, prefix string) (*Tags, error) { 305 unlock, err := r.mu.Lock() 306 if err != nil { 307 return nil, err 308 } 309 defer unlock() 310 311 r.tagsOnce.Do(func() { r.loadTags(ctx) }) 312 tags := &Tags{ 313 // None of the other VCS provide a reasonable way to compute TagSum 314 // without downloading the whole repo, so we only include VCS and URL 315 // in the Origin. 316 Origin: &Origin{ 317 VCS: r.cmd.vcs, 318 URL: r.remote, 319 }, 320 List: []Tag{}, 321 } 322 for tag := range r.tags { 323 if strings.HasPrefix(tag, prefix) { 324 tags.List = append(tags.List, Tag{tag, ""}) 325 } 326 } 327 sort.Slice(tags.List, func(i, j int) bool { 328 return tags.List[i].Name < tags.List[j].Name 329 }) 330 return tags, nil 331 } 332 333 func (r *vcsRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) { 334 unlock, err := r.mu.Lock() 335 if err != nil { 336 return nil, err 337 } 338 defer unlock() 339 340 if rev == "latest" { 341 rev = r.cmd.latest 342 } 343 r.branchesOnce.Do(func() { r.loadBranches(ctx) }) 344 revOK := (r.cmd.badLocalRevRE == nil || !r.cmd.badLocalRevRE.MatchString(rev)) && !r.branches[rev] 345 if revOK { 346 if info, err := r.statLocal(ctx, rev); err == nil { 347 return info, nil 348 } 349 } 350 351 r.fetchOnce.Do(func() { r.fetch(ctx) }) 352 if r.fetchErr != nil { 353 return nil, r.fetchErr 354 } 355 info, err := r.statLocal(ctx, rev) 356 if err != nil { 357 return nil, err 358 } 359 if !revOK { 360 info.Version = info.Name 361 } 362 return info, nil 363 } 364 365 func (r *vcsRepo) fetch(ctx context.Context) { 366 if len(r.cmd.fetch) > 0 { 367 release, err := base.AcquireNet() 368 if err != nil { 369 r.fetchErr = err 370 return 371 } 372 _, r.fetchErr = Run(ctx, r.dir, r.cmd.fetch) 373 release() 374 } 375 } 376 377 func (r *vcsRepo) statLocal(ctx context.Context, rev string) (*RevInfo, error) { 378 out, err := Run(ctx, r.dir, r.cmd.statLocal(rev, r.remote)) 379 if err != nil { 380 return nil, &UnknownRevisionError{Rev: rev} 381 } 382 info, err := r.cmd.parseStat(rev, string(out)) 383 if err != nil { 384 return nil, err 385 } 386 if info.Origin == nil { 387 info.Origin = new(Origin) 388 } 389 info.Origin.VCS = r.cmd.vcs 390 info.Origin.URL = r.remote 391 return info, nil 392 } 393 394 func (r *vcsRepo) Latest(ctx context.Context) (*RevInfo, error) { 395 return r.Stat(ctx, "latest") 396 } 397 398 func (r *vcsRepo) ReadFile(ctx context.Context, rev, file string, maxSize int64) ([]byte, error) { 399 if rev == "latest" { 400 rev = r.cmd.latest 401 } 402 _, err := r.Stat(ctx, rev) // download rev into local repo 403 if err != nil { 404 return nil, err 405 } 406 407 // r.Stat acquires r.mu, so lock after that. 408 unlock, err := r.mu.Lock() 409 if err != nil { 410 return nil, err 411 } 412 defer unlock() 413 414 out, err := Run(ctx, r.dir, r.cmd.readFile(rev, file, r.remote)) 415 if err != nil { 416 return nil, fs.ErrNotExist 417 } 418 return out, nil 419 } 420 421 func (r *vcsRepo) RecentTag(ctx context.Context, rev, prefix string, allowed func(string) bool) (tag string, err error) { 422 // We don't technically need to lock here since we're returning an error 423 // uncondititonally, but doing so anyway will help to avoid baking in 424 // lock-inversion bugs. 425 unlock, err := r.mu.Lock() 426 if err != nil { 427 return "", err 428 } 429 defer unlock() 430 431 return "", vcsErrorf("vcs %s: RecentTag: %w", r.cmd.vcs, errors.ErrUnsupported) 432 } 433 434 func (r *vcsRepo) DescendsFrom(ctx context.Context, rev, tag string) (bool, error) { 435 unlock, err := r.mu.Lock() 436 if err != nil { 437 return false, err 438 } 439 defer unlock() 440 441 return false, vcsErrorf("vcs %s: DescendsFrom: %w", r.cmd.vcs, errors.ErrUnsupported) 442 } 443 444 func (r *vcsRepo) ReadZip(ctx context.Context, rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) { 445 if r.cmd.readZip == nil && r.cmd.doReadZip == nil { 446 return nil, vcsErrorf("vcs %s: ReadZip: %w", r.cmd.vcs, errors.ErrUnsupported) 447 } 448 449 unlock, err := r.mu.Lock() 450 if err != nil { 451 return nil, err 452 } 453 defer unlock() 454 455 if rev == "latest" { 456 rev = r.cmd.latest 457 } 458 f, err := os.CreateTemp("", "go-readzip-*.zip") 459 if err != nil { 460 return nil, err 461 } 462 if r.cmd.doReadZip != nil { 463 lw := &limitedWriter{ 464 W: f, 465 N: maxSize, 466 ErrLimitReached: errors.New("ReadZip: encoded file exceeds allowed size"), 467 } 468 err = r.cmd.doReadZip(ctx, lw, r.dir, rev, subdir, r.remote) 469 if err == nil { 470 _, err = f.Seek(0, io.SeekStart) 471 } 472 } else if r.cmd.vcs == "fossil" { 473 // If you run 474 // fossil zip -R .fossil --name prefix trunk /tmp/x.zip 475 // fossil fails with "unable to create directory /tmp" [sic]. 476 // Change the command to run in /tmp instead, 477 // replacing the -R argument with an absolute path. 478 args := r.cmd.readZip(rev, subdir, r.remote, filepath.Base(f.Name())) 479 for i := range args { 480 if args[i] == ".fossil" { 481 args[i] = filepath.Join(r.dir, ".fossil") 482 } 483 } 484 _, err = Run(ctx, filepath.Dir(f.Name()), args) 485 } else { 486 _, err = Run(ctx, r.dir, r.cmd.readZip(rev, subdir, r.remote, f.Name())) 487 } 488 if err != nil { 489 f.Close() 490 os.Remove(f.Name()) 491 return nil, err 492 } 493 return &deleteCloser{f}, nil 494 } 495 496 // deleteCloser is a file that gets deleted on Close. 497 type deleteCloser struct { 498 *os.File 499 } 500 501 func (d *deleteCloser) Close() error { 502 defer os.Remove(d.File.Name()) 503 return d.File.Close() 504 } 505 506 func hgParseStat(rev, out string) (*RevInfo, error) { 507 f := strings.Fields(out) 508 if len(f) < 3 { 509 return nil, vcsErrorf("unexpected response from hg log: %q", out) 510 } 511 hash := f[0] 512 version := rev 513 if strings.HasPrefix(hash, version) { 514 version = hash // extend to full hash 515 } 516 t, err := strconv.ParseInt(f[1], 10, 64) 517 if err != nil { 518 return nil, vcsErrorf("invalid time from hg log: %q", out) 519 } 520 521 var tags []string 522 for _, tag := range f[3:] { 523 if tag != "tip" { 524 tags = append(tags, tag) 525 } 526 } 527 sort.Strings(tags) 528 529 info := &RevInfo{ 530 Origin: &Origin{ 531 Hash: hash, 532 }, 533 Name: hash, 534 Short: ShortenSHA1(hash), 535 Time: time.Unix(t, 0).UTC(), 536 Version: version, 537 Tags: tags, 538 } 539 return info, nil 540 } 541 542 func bzrParseStat(rev, out string) (*RevInfo, error) { 543 var revno int64 544 var tm time.Time 545 for _, line := range strings.Split(out, "\n") { 546 if line == "" || line[0] == ' ' || line[0] == '\t' { 547 // End of header, start of commit message. 548 break 549 } 550 if line[0] == '-' { 551 continue 552 } 553 before, after, found := strings.Cut(line, ":") 554 if !found { 555 // End of header, start of commit message. 556 break 557 } 558 key, val := before, strings.TrimSpace(after) 559 switch key { 560 case "revno": 561 if j := strings.Index(val, " "); j >= 0 { 562 val = val[:j] 563 } 564 i, err := strconv.ParseInt(val, 10, 64) 565 if err != nil { 566 return nil, vcsErrorf("unexpected revno from bzr log: %q", line) 567 } 568 revno = i 569 case "timestamp": 570 j := strings.Index(val, " ") 571 if j < 0 { 572 return nil, vcsErrorf("unexpected timestamp from bzr log: %q", line) 573 } 574 t, err := time.Parse("2006-01-02 15:04:05 -0700", val[j+1:]) 575 if err != nil { 576 return nil, vcsErrorf("unexpected timestamp from bzr log: %q", line) 577 } 578 tm = t.UTC() 579 } 580 } 581 if revno == 0 || tm.IsZero() { 582 return nil, vcsErrorf("unexpected response from bzr log: %q", out) 583 } 584 585 info := &RevInfo{ 586 Name: strconv.FormatInt(revno, 10), 587 Short: fmt.Sprintf("%012d", revno), 588 Time: tm, 589 Version: rev, 590 } 591 return info, nil 592 } 593 594 func fossilParseStat(rev, out string) (*RevInfo, error) { 595 for _, line := range strings.Split(out, "\n") { 596 if strings.HasPrefix(line, "uuid:") || strings.HasPrefix(line, "hash:") { 597 f := strings.Fields(line) 598 if len(f) != 5 || len(f[1]) != 40 || f[4] != "UTC" { 599 return nil, vcsErrorf("unexpected response from fossil info: %q", line) 600 } 601 t, err := time.Parse(time.DateTime, f[2]+" "+f[3]) 602 if err != nil { 603 return nil, vcsErrorf("unexpected response from fossil info: %q", line) 604 } 605 hash := f[1] 606 version := rev 607 if strings.HasPrefix(hash, version) { 608 version = hash // extend to full hash 609 } 610 info := &RevInfo{ 611 Origin: &Origin{ 612 Hash: hash, 613 }, 614 Name: hash, 615 Short: ShortenSHA1(hash), 616 Time: t, 617 Version: version, 618 } 619 return info, nil 620 } 621 } 622 return nil, vcsErrorf("unexpected response from fossil info: %q", out) 623 } 624 625 type limitedWriter struct { 626 W io.Writer 627 N int64 628 ErrLimitReached error 629 } 630 631 func (l *limitedWriter) Write(p []byte) (n int, err error) { 632 if l.N > 0 { 633 max := len(p) 634 if l.N < int64(max) { 635 max = int(l.N) 636 } 637 n, err = l.W.Write(p[:max]) 638 l.N -= int64(n) 639 if err != nil || n >= len(p) { 640 return n, err 641 } 642 } 643 644 return n, l.ErrLimitReached 645 }