github.com/creativeprojects/go-selfupdate@v1.2.0/detect.go (about)

     1  package selfupdate
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"regexp"
     7  	"strings"
     8  
     9  	"github.com/Masterminds/semver/v3"
    10  )
    11  
    12  var reVersion = regexp.MustCompile(`\d+\.\d+\.\d+`)
    13  
    14  // DetectLatest tries to get the latest version from the source provider.
    15  // It fetches releases information from the source provider and find out the latest release with matching the tag names and asset names.
    16  // Drafts and pre-releases are ignored.
    17  // Assets would be suffixed by the OS name and the arch name such as 'foo_linux_amd64' where 'foo' is a command name.
    18  // '-' can also be used as a separator. File can be compressed with zip, gzip, zxip, bzip2, tar&gzip or tar&zxip.
    19  // So the asset can have a file extension for the corresponding compression format such as '.zip'.
    20  // On Windows, '.exe' also can be contained such as 'foo_windows_amd64.exe.zip'.
    21  func (up *Updater) DetectLatest(ctx context.Context, repository Repository) (release *Release, found bool, err error) {
    22  	return up.DetectVersion(ctx, repository, "")
    23  }
    24  
    25  // DetectVersion tries to get the given version from the source provider.
    26  // And version indicates the required version.
    27  func (up *Updater) DetectVersion(ctx context.Context, repository Repository, version string) (release *Release, found bool, err error) {
    28  	rels, err := up.source.ListReleases(ctx, repository)
    29  	if err != nil {
    30  		return nil, false, err
    31  	}
    32  
    33  	rel, asset, ver, found := up.findReleaseAndAsset(rels, version)
    34  	if !found {
    35  		return nil, false, nil
    36  	}
    37  
    38  	return up.validateReleaseAsset(repository, rel, asset, ver)
    39  }
    40  
    41  func (up *Updater) validateReleaseAsset(
    42  	repository Repository,
    43  	rel SourceRelease,
    44  	asset SourceAsset,
    45  	ver *semver.Version,
    46  ) (release *Release, found bool, err error) {
    47  	log.Printf("Successfully fetched release %s, name: %s, URL: %s, asset: %s",
    48  		rel.GetTagName(),
    49  		rel.GetName(),
    50  		rel.GetURL(),
    51  		asset.GetBrowserDownloadURL(),
    52  	)
    53  
    54  	release = &Release{
    55  		version:            ver,
    56  		repository:         repository,
    57  		AssetURL:           asset.GetBrowserDownloadURL(),
    58  		AssetByteSize:      asset.GetSize(),
    59  		AssetID:            asset.GetID(),
    60  		AssetName:          asset.GetName(),
    61  		ValidationAssetID:  -1,
    62  		ValidationAssetURL: "",
    63  		URL:                rel.GetURL(),
    64  		ReleaseID:          rel.GetID(),
    65  		ReleaseNotes:       rel.GetReleaseNotes(),
    66  		Name:               rel.GetName(),
    67  		PublishedAt:        rel.GetPublishedAt(),
    68  		Prerelease:         rel.GetPrerelease(),
    69  		OS:                 up.os,
    70  		Arch:               up.arch,
    71  		Arm:                up.arm,
    72  	}
    73  
    74  	if up.validator != nil {
    75  		validationName := up.validator.GetValidationAssetName(asset.GetName())
    76  		validationAsset, ok := findValidationAsset(rel, validationName)
    77  		if ok {
    78  			release.ValidationAssetID = validationAsset.GetID()
    79  			release.ValidationAssetURL = validationAsset.GetBrowserDownloadURL()
    80  		} else {
    81  			err = fmt.Errorf("%w: %q", ErrValidationAssetNotFound, validationName)
    82  		}
    83  
    84  		for err == nil {
    85  			release.ValidationChain = append(release.ValidationChain, struct {
    86  				ValidationAssetID                       int64
    87  				ValidationAssetName, ValidationAssetURL string
    88  			}{
    89  				ValidationAssetID:   validationAsset.GetID(),
    90  				ValidationAssetName: validationAsset.GetName(),
    91  				ValidationAssetURL:  validationAsset.GetBrowserDownloadURL(),
    92  			})
    93  
    94  			if len(release.ValidationChain) > 20 {
    95  				err = fmt.Errorf("failed adding validation step %q: recursive validation nesting depth exceeded", validationAsset.GetName())
    96  				break
    97  			}
    98  
    99  			if rv, ok := up.validator.(RecursiveValidator); ok && rv.MustContinueValidation(validationAsset.GetName()) {
   100  				validationName = up.validator.GetValidationAssetName(validationAsset.GetName())
   101  				if validationName != validationAsset.GetName() {
   102  					validationAsset, ok = findValidationAsset(rel, validationName)
   103  					if !ok {
   104  						err = fmt.Errorf("%w: %q", ErrValidationAssetNotFound, validationName)
   105  					}
   106  					continue
   107  				}
   108  			}
   109  
   110  			break
   111  		}
   112  	}
   113  
   114  	if found = err == nil; !found {
   115  		release = nil
   116  	}
   117  	return
   118  }
   119  
   120  // findValidationAsset returns the source asset used for validation
   121  func findValidationAsset(rel SourceRelease, validationName string) (SourceAsset, bool) {
   122  	for _, asset := range rel.GetAssets() {
   123  		if asset.GetName() == validationName {
   124  			return asset, true
   125  		}
   126  	}
   127  	return nil, false
   128  }
   129  
   130  // findReleaseAndAsset returns the release and asset matching the target version, or latest if target version is empty
   131  func (up *Updater) findReleaseAndAsset(rels []SourceRelease, targetVersion string) (SourceRelease, SourceAsset, *semver.Version, bool) {
   132  	// we put the detected arch at the end of the list: that's fine for ARM so far,
   133  	// as the additional arch are more accurate than the generic one
   134  	for _, arch := range append(generateAdditionalArch(up.arch, up.arm), up.arch) {
   135  		release, asset, version, found := up.findReleaseAndAssetForArch(arch, rels, targetVersion)
   136  		if found {
   137  			return release, asset, version, found
   138  		}
   139  	}
   140  
   141  	return nil, nil, nil, false
   142  }
   143  
   144  func (up *Updater) findReleaseAndAssetForArch(arch string, rels []SourceRelease, targetVersion string,
   145  ) (SourceRelease, SourceAsset, *semver.Version, bool) {
   146  	var ver *semver.Version
   147  	var asset SourceAsset
   148  	var release SourceRelease
   149  
   150  	log.Printf("Searching for a possible candidate for os %q and arch %q", up.os, arch)
   151  
   152  	// Find the latest version from the list of releases.
   153  	// Returned list from GitHub API is in the order of the date when created.
   154  	for _, rel := range rels {
   155  		if a, v, ok := up.findAssetFromRelease(rel, up.getSuffixes(arch), targetVersion); ok {
   156  			// Note: any version with suffix is less than any version without suffix.
   157  			// e.g. 0.0.1 > 0.0.1-beta
   158  			if release == nil || v.GreaterThan(ver) {
   159  				ver = v
   160  				asset = a
   161  				release = rel
   162  			}
   163  		}
   164  	}
   165  
   166  	if release == nil {
   167  		log.Printf("Could not find any release for os %q and arch %q", up.os, arch)
   168  		return nil, nil, nil, false
   169  	}
   170  
   171  	return release, asset, ver, true
   172  }
   173  
   174  func (up *Updater) findAssetFromRelease(rel SourceRelease, suffixes []string, targetVersion string) (SourceAsset, *semver.Version, bool) {
   175  	if rel == nil {
   176  		log.Print("No source release information")
   177  		return nil, nil, false
   178  	}
   179  	if targetVersion != "" && targetVersion != rel.GetTagName() {
   180  		log.Printf("Skip %s not matching to specified version %s", rel.GetTagName(), targetVersion)
   181  		return nil, nil, false
   182  	}
   183  
   184  	if rel.GetDraft() && !up.draft && targetVersion == "" {
   185  		log.Printf("Skip draft version %s", rel.GetTagName())
   186  		return nil, nil, false
   187  	}
   188  	if rel.GetPrerelease() && !up.prerelease && targetVersion == "" {
   189  		log.Printf("Skip pre-release version %s", rel.GetTagName())
   190  		return nil, nil, false
   191  	}
   192  
   193  	verText := rel.GetTagName()
   194  	indices := reVersion.FindStringIndex(verText)
   195  	if indices == nil {
   196  		log.Printf("Skip version not adopting semver: %s", verText)
   197  		return nil, nil, false
   198  	}
   199  	if indices[0] > 0 {
   200  		verText = verText[indices[0]:]
   201  	}
   202  
   203  	// If semver cannot parse the version text, it means that the text is not adopting
   204  	// the semantic versioning. So it should be skipped.
   205  	ver, err := semver.NewVersion(verText)
   206  	if err != nil {
   207  		log.Printf("Failed to parse a semantic version: %s", verText)
   208  		return nil, nil, false
   209  	}
   210  
   211  	for _, asset := range rel.GetAssets() {
   212  		// try names first
   213  		name := asset.GetName()
   214  		// case insensitive search
   215  		name = strings.ToLower(name)
   216  
   217  		if up.hasFilters() {
   218  			if up.assetMatchFilters(name) {
   219  				return asset, ver, true
   220  			}
   221  		} else {
   222  			if up.assetMatchSuffixes(name, suffixes) {
   223  				return asset, ver, true
   224  			}
   225  		}
   226  
   227  		// then try from filename (Gitlab can assign human names to release assets)
   228  		name = asset.GetBrowserDownloadURL()
   229  		// case insensitive search
   230  		name = strings.ToLower(name)
   231  
   232  		if up.hasFilters() {
   233  			if up.assetMatchFilters(name) {
   234  				return asset, ver, true
   235  			}
   236  		} else {
   237  			if up.assetMatchSuffixes(name, suffixes) {
   238  				return asset, ver, true
   239  			}
   240  		}
   241  	}
   242  
   243  	log.Printf("No suitable asset was found in release %s", rel.GetTagName())
   244  	return nil, nil, false
   245  }
   246  
   247  func (up *Updater) hasFilters() bool {
   248  	return len(up.filters) > 0
   249  }
   250  
   251  func (up *Updater) assetMatchFilters(name string) bool {
   252  	if len(up.filters) > 0 {
   253  		// if some filters are defined, match them: if any one matches, the asset is selected
   254  		for _, filter := range up.filters {
   255  			if filter.MatchString(name) {
   256  				log.Printf("Selected filtered asset: %s", name)
   257  				return true
   258  			}
   259  			log.Printf("Skipping asset %q not matching filter %v\n", name, filter)
   260  		}
   261  	}
   262  	return false
   263  }
   264  
   265  func (up *Updater) assetMatchSuffixes(name string, suffixes []string) bool {
   266  	for _, suffix := range suffixes {
   267  		if strings.HasSuffix(name, suffix) { // require version, arch etc
   268  			// assuming a unique artifact will be a match (or first one will do)
   269  			return true
   270  		}
   271  	}
   272  	return false
   273  }
   274  
   275  // getSuffixes returns all candidates to check against the assets
   276  func (up *Updater) getSuffixes(arch string) []string {
   277  	suffixes := make([]string, 0)
   278  	for _, sep := range []rune{'_', '-'} {
   279  		for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".gzip", ".gz", ".tar.xz", ".xz", ".bz2", ""} {
   280  			suffix := fmt.Sprintf("%s%c%s%s", up.os, sep, arch, ext)
   281  			suffixes = append(suffixes, suffix)
   282  			if up.os == "windows" {
   283  				suffix = fmt.Sprintf("%s%c%s.exe%s", up.os, sep, arch, ext)
   284  				suffixes = append(suffixes, suffix)
   285  			}
   286  		}
   287  	}
   288  	return suffixes
   289  }