blob: e7f9c503ef6123ea44c6fa3dcae2d6bb1d465677 [file] [log] [blame]
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package frontend
import (
"context"
"fmt"
"path"
"sort"
"strings"
"unicode"
"golang.org/x/mod/semver"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/postgres"
"golang.org/x/pkgsite/internal/stdlib"
"golang.org/x/pkgsite/internal/version"
)
// VersionsDetails contains the hierarchy of version summary information used
// to populate the version tab. Version information is organized into separate
// lists, one for each (ModulePath, Major Version) pair.
type VersionsDetails struct {
// ThisModule is the slice of VersionLists with the same module path as the
// current package.
ThisModule []*VersionList
// IncompatibleModules is the slice of the VersionsLists with the same
// module path as the current package, but with incompatible versions.
IncompatibleModules []*VersionList
// OtherModules is the slice of VersionLists with a different module path
// from the current package.
OtherModules []string
}
// VersionListKey identifies a version list on the versions tab. We have a
// separate VersionList for each major version of a module series.
// Notably we have more version lists than module paths: v0 and v1 module
// versions are in separate version lists, despite having the same module path.
// Also note that major version isn't sufficient as a key: there are packages
// contained in the same major version of different modules, for example
// github.com/hashicorp/vault/api, which exists in v1 of both of
// github.com/hashicorp/vault and github.com/hashicorp/vault/api.
type VersionListKey struct {
// ModulePath is the module path of this major version.
ModulePath string
// Major is the major version string (e.g. v1, v2)
Major string
// Incompatible indicates whether the VersionListKey represents an
// incompatible module version.
Incompatible bool
}
// VersionList holds all versions corresponding to a unique (module path,
// major version) tuple in the version hierarchy.
type VersionList struct {
VersionListKey
// Deprecated indicates whether the major version is deprecated.
Deprecated bool
// DeprecationComment holds the reason for deprecation, if any.
DeprecationComment string
// Versions holds the nested version summaries, organized in descending
// semver order.
Versions []*VersionSummary
}
// VersionSummary holds data required to format the version link on the
// versions tab.
type VersionSummary struct {
CommitTime string
// Link to this version, for use in the anchor href.
Link string
Version string
Retracted bool
RetractionRationale string
IsMinor bool
Symbols [][]*Symbol
Vulns []Vuln
}
func fetchVersionsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta, getVulnEntries vulnEntriesFunc) (*VersionsDetails, error) {
db, ok := ds.(*postgres.DB)
if !ok {
// The proxydatasource does not support the imported by page.
return nil, datasourceNotSupportedErr()
}
versions, err := db.GetVersionsForPath(ctx, um.Path)
if err != nil {
return nil, err
}
sh := internal.NewSymbolHistory()
if !um.IsCommand() {
sh, err = db.GetSymbolHistory(ctx, um.Path, um.ModulePath)
if err != nil {
return nil, err
}
}
linkify := func(mi *internal.ModuleInfo) string {
// Here we have only version information, but need to construct the full
// import path of the package corresponding to this version.
var versionPath string
if mi.ModulePath == stdlib.ModulePath {
versionPath = um.Path
} else {
versionPath = pathInVersion(internal.V1Path(um.Path, um.ModulePath), mi)
}
return constructUnitURL(versionPath, mi.ModulePath, linkVersion(mi.ModulePath, mi.Version, mi.Version))
}
return buildVersionDetails(ctx, um.ModulePath, versions, sh, linkify, getVulnEntries), nil
}
// pathInVersion constructs the full import path of the package corresponding
// to mi, given its v1 path. To do this, we first compute the suffix of the
// package path in the given module series, and then append it to the real
// (versioned) module path.
//
// For example: if we're considering package foo.com/v3/bar/baz, and encounter
// module version foo.com/bar/v2, we do the following:
// 1) Start with the v1Path foo.com/bar/baz.
// 2) Trim off the version series path foo.com/bar to get 'baz'.
// 3) Join with the versioned module path foo.com/bar/v2 to get
// foo.com/bar/v2/baz.
// ...being careful about slashes along the way.
func pathInVersion(v1Path string, mi *internal.ModuleInfo) string {
suffix := internal.Suffix(v1Path, mi.SeriesPath())
if suffix == "" {
return mi.ModulePath
}
return path.Join(mi.ModulePath, suffix)
}
// buildVersionDetails constructs the version hierarchy to be rendered on the
// versions tab, organizing major versions into those that have the same module
// path as the package version under consideration, and those that don't. The
// given versions MUST be sorted first by module path and then by semver.
func buildVersionDetails(ctx context.Context, currentModulePath string,
modInfos []*internal.ModuleInfo,
sh *internal.SymbolHistory,
linkify func(v *internal.ModuleInfo) string,
getVulnEntries vulnEntriesFunc,
) *VersionsDetails {
// lists organizes versions by VersionListKey.
lists := make(map[VersionListKey]*VersionList)
// seenLists tracks the order in which we encounter entries of each version
// list. We want to preserve this order.
var seenLists []VersionListKey
for _, mi := range modInfos {
// Try to resolve the most appropriate major version for this version. If
// we detect a +incompatible version (when the path version does not match
// the sematic version), we prefer the path version.
major := semver.Major(mi.Version)
if mi.ModulePath == stdlib.ModulePath {
var err error
major, err = stdlib.MajorVersionForVersion(mi.Version)
if err != nil {
panic(err)
}
}
// We prefer the path major version except for v1 import paths where the
// semver major version is v0. In this case, we prefer the more specific
// semver version.
pathMajor := internal.MajorVersionForModule(mi.ModulePath)
if pathMajor != "" {
major = pathMajor
} else if version.IsIncompatible(mi.Version) {
major = semver.Major(mi.Version)
} else if major != "v0" && !strings.HasPrefix(major, "go") {
major = "v1"
}
key := VersionListKey{
ModulePath: mi.ModulePath,
Major: major,
Incompatible: version.IsIncompatible(mi.Version),
}
vs := &VersionSummary{
Link: linkify(mi),
CommitTime: absoluteTime(mi.CommitTime),
Version: linkVersion(mi.ModulePath, mi.Version, mi.Version),
IsMinor: isMinor(mi.Version),
Retracted: mi.Retracted,
RetractionRationale: shortRationale(mi.RetractionRationale),
}
if sv := sh.SymbolsAtVersion(mi.Version); sv != nil {
vs.Symbols = symbolsForVersion(linkify(mi), sv)
}
if experiment.IsActive(ctx, internal.ExperimentVulns) {
vs.Vulns = Vulns(mi.ModulePath, mi.Version, "", getVulnEntries)
}
vl := lists[key]
if vl == nil {
seenLists = append(seenLists, key)
vl = &VersionList{
VersionListKey: key,
Deprecated: mi.Deprecated,
DeprecationComment: shortRationale(mi.DeprecationComment),
}
lists[key] = vl
}
vl.Versions = append(vl.Versions, vs)
}
var details VersionsDetails
other := map[string]bool{}
for _, key := range seenLists {
vl := lists[key]
if key.ModulePath == currentModulePath {
if key.Incompatible {
details.IncompatibleModules = append(details.IncompatibleModules, vl)
} else {
details.ThisModule = append(details.ThisModule, vl)
}
} else {
other[key.ModulePath] = true
}
}
for m := range other {
details.OtherModules = append(details.OtherModules, m)
}
// Sort for testing.
sort.Strings(details.OtherModules)
return &details
}
// isMinor reports whether v is a release version where the patch version is 0.
// It is assumed that v is a valid semantic version.
func isMinor(v string) bool {
if version.IsIncompatible(v) {
return false
}
typ, err := version.ParseType(v)
if err != nil {
// This should never happen because v will always be a valid semantic
// version.
return false
}
if typ == version.TypePrerelease || typ == version.TypePseudo {
return false
}
return strings.HasSuffix(strings.TrimPrefix(v, semver.MajorMinor(v)), ".0")
}
// formatVersion formats a more readable representation of the given version
// string. On any parsing error, it simply returns the input unmodified.
//
// For pseudo versions, the version string will use a shorten commit hash of 7
// characters to identify the version, and hide timestamp using ellipses.
//
// For any version string longer than 25 characters, the pre-release string will be
// truncated, such that the string displayed is exactly 25 characters, including the ellipses.
//
// See TestFormatVersion for examples.
func formatVersion(v string) string {
const maxLen = 25
if len(v) <= maxLen {
return v
}
vType, err := version.ParseType(v)
if err != nil {
log.Errorf(context.TODO(), "formatVersion(%q): error parsing version: %v", v, err)
return v
}
if vType != version.TypePseudo {
// If the version is release or prerelease, return a version string of
// maxLen by truncating the end of the string. maxLen is inclusive of
// the "..." characters.
return v[:maxLen-3] + "..."
}
// The version string will have a max length of 25:
// base: "vX.Y.Z-prerelease.0" = up to 15
// ellipse: "..." = 3
// commit: "-abcdefa" = 7
commit := shorten(pseudoVersionRev(v), 7)
base := shorten(pseudoVersionBase(v), 15)
return fmt.Sprintf("%s...-%s", base, commit)
}
// shorten shortens the string s to maxLen by removing the trailing characters.
func shorten(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen]
}
return s
}
// shortRationale returns a rationale string that is safe
// to print in a terminal. It returns hard-coded strings if the rationale
// is empty, too long, or contains non-printable characters.
func shortRationale(rationale string) string {
// Copied with slight modifications from
// https://go.googlesource.com/go/+/87c6fa4f473f178f7d931ddadd10c76444f8dc7b/src/cmd/go/internal/modload/modfile.go#208.
const maxRationaleBytes = 500
if i := strings.Index(rationale, "\n"); i >= 0 {
rationale = rationale[:i]
}
rationale = strings.TrimSpace(rationale)
if rationale == "" {
return ""
}
if len(rationale) > maxRationaleBytes {
return "(rationale omitted: too long)"
}
for _, r := range rationale {
if !unicode.IsGraphic(r) && !unicode.IsSpace(r) {
return "(rationale omitted: contains non-printable characters)"
}
}
// NOTE: the go.mod parser rejects invalid UTF-8, so we don't check that here.
return rationale
}
// pseudoVersionRev extracts the pseudo version base, excluding the timestamp.
// It assumes the pseudo version is correctly formatted.
//
// See TestPseudoVersionBase for examples.
func pseudoVersionBase(v string) string {
parts := strings.Split(v, "-")
if len(parts) != 3 {
mid := strings.Join(parts[1:len(parts)-1], "-")
parts = []string{parts[0], mid, parts[2]}
}
// The version string will always be split into one
// of these 3 parts:
// 1. [vX.0.0, yyyymmddhhmmss, abcdefabcdef]
// 2. [vX.Y.Z, pre.0.yyyymmddhhmmss, abcdefabcdef]
// 3. [vX.Y.Z, 0.yyyymmddhhmmss, abcdefabcdef]
p := strings.Split(parts[1], ".")
var suffix string
if len(p) > 0 {
// There is a "pre.0" or "0" prefix in the second element.
suffix = strings.Join(p[0:len(p)-1], ".")
}
return fmt.Sprintf("%s-%s", parts[0], suffix)
}
// pseudoVersionRev extracts the first 7 characters of the commit identifier
// from a pseudo version string. It assumes the pseudo version is correctly
// formatted.
func pseudoVersionRev(v string) string {
v = strings.TrimSuffix(v, "+incompatible")
j := strings.LastIndex(v, "-")
return v[j+1:]
}
// displayVersion returns the version string, formatted for display.
func displayVersion(modulePath, requestedVersion, resolvedVersion string) string {
if modulePath == stdlib.ModulePath {
if stdlib.SupportedBranches[requestedVersion] || strings.HasPrefix(resolvedVersion, "v0.0.0") {
commit := strings.Split(resolvedVersion, "-")[2]
// If the resolvedVersion is a pseudoversion and the
// requestedVersion is not dev.fuzz, display "master (<commit>)".
// std doesn't have actual pseudoversions, so the only ones we
// support are "master" and "dev.fuzz".
v := version.Master
if requestedVersion == stdlib.DevFuzz ||
requestedVersion == stdlib.DevBoringCrypto {
v = requestedVersion
}
return fmt.Sprintf("%s (%s)", v, commit[0:7])
}
return goTagForVersion(resolvedVersion)
}
return formatVersion(resolvedVersion)
}
// linkVersion returns the version string, suitable for use in
// a link to this site.
// See TestLinkVersion for examples.
func linkVersion(modulePath, requestedVersion, resolvedVersion string) string {
if modulePath == stdlib.ModulePath {
if strings.HasPrefix(resolvedVersion, "go") {
return resolvedVersion
}
if stdlib.SupportedBranches[requestedVersion] {
return requestedVersion
}
return goTagForVersion(resolvedVersion)
}
return resolvedVersion
}
// goTagForVersion returns the Go tag corresponding to a given semantic
// version. It should only be used if we are 100% sure the version will
// correspond to a Go tag, such as when we are fetching the version from the
// database.
func goTagForVersion(v string) string {
tag, err := stdlib.TagForVersion(v)
if err != nil {
log.Errorf(context.TODO(), "goTagForVersion(%q): %v", v, err)
return "unknown"
}
return tag
}