zotregistry.dev/zot@v1.4.4-0.20240314164342-eec277e14d20/pkg/cli/client/client.go (about)

     1  //go:build search
     2  // +build search
     3  
     4  package client
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"net/url"
    14  	"strconv"
    15  	"strings"
    16  	"sync"
    17  	"time"
    18  
    19  	ispec "github.com/opencontainers/image-spec/specs-go/v1"
    20  	"github.com/sigstore/cosign/v2/pkg/oci/remote"
    21  
    22  	zerr "zotregistry.dev/zot/errors"
    23  	"zotregistry.dev/zot/pkg/common"
    24  )
    25  
    26  var (
    27  	httpClientsMap = make(map[string]*http.Client) //nolint: gochecknoglobals
    28  	httpClientLock sync.Mutex                      //nolint: gochecknoglobals
    29  )
    30  
    31  func makeGETRequest(ctx context.Context, url, username, password string,
    32  	verifyTLS bool, debug bool, resultsPtr interface{}, configWriter io.Writer,
    33  ) (http.Header, error) {
    34  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    35  	if err != nil {
    36  		return nil, err
    37  	}
    38  
    39  	req.SetBasicAuth(username, password)
    40  
    41  	return doHTTPRequest(req, verifyTLS, debug, resultsPtr, configWriter)
    42  }
    43  
    44  func makeHEADRequest(ctx context.Context, url, username, password string, verifyTLS bool,
    45  	debug bool,
    46  ) (http.Header, error) {
    47  	req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
    48  	if err != nil {
    49  		return nil, err
    50  	}
    51  
    52  	req.SetBasicAuth(username, password)
    53  
    54  	return doHTTPRequest(req, verifyTLS, debug, nil, io.Discard)
    55  }
    56  
    57  func makeGraphQLRequest(ctx context.Context, url, query, username,
    58  	password string, verifyTLS bool, debug bool, resultsPtr interface{}, configWriter io.Writer,
    59  ) error {
    60  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, bytes.NewBufferString(query))
    61  	if err != nil {
    62  		return err
    63  	}
    64  
    65  	q := req.URL.Query()
    66  	q.Add("query", query)
    67  
    68  	req.URL.RawQuery = q.Encode()
    69  
    70  	req.SetBasicAuth(username, password)
    71  	req.Header.Add("Content-Type", "application/json")
    72  
    73  	_, err = doHTTPRequest(req, verifyTLS, debug, resultsPtr, configWriter)
    74  	if err != nil {
    75  		return err
    76  	}
    77  
    78  	return nil
    79  }
    80  
    81  func doHTTPRequest(req *http.Request, verifyTLS bool, debug bool,
    82  	resultsPtr interface{}, configWriter io.Writer,
    83  ) (http.Header, error) {
    84  	var httpClient *http.Client
    85  
    86  	var err error
    87  
    88  	host := req.Host
    89  
    90  	httpClientLock.Lock()
    91  
    92  	if httpClientsMap[host] == nil {
    93  		httpClient, err = common.CreateHTTPClient(verifyTLS, host, "")
    94  		if err != nil {
    95  			return nil, err
    96  		}
    97  
    98  		httpClientsMap[host] = httpClient
    99  	} else {
   100  		httpClient = httpClientsMap[host]
   101  	}
   102  
   103  	httpClientLock.Unlock()
   104  
   105  	if debug {
   106  		fmt.Fprintln(configWriter, "[debug] ", req.Method, " ", req.URL, "[request header] ", req.Header)
   107  	}
   108  
   109  	resp, err := httpClient.Do(req)
   110  	if err != nil {
   111  		return nil, err
   112  	}
   113  
   114  	if debug {
   115  		fmt.Fprintln(configWriter, "[debug] ", req.Method, req.URL, "[status] ",
   116  			resp.StatusCode, " ", "[response header] ", resp.Header)
   117  	}
   118  
   119  	defer resp.Body.Close()
   120  
   121  	if resp.StatusCode != http.StatusOK {
   122  		var err error
   123  
   124  		switch resp.StatusCode {
   125  		case http.StatusNotFound:
   126  			err = zerr.ErrURLNotFound
   127  		case http.StatusUnauthorized:
   128  			err = zerr.ErrUnauthorizedAccess
   129  		default:
   130  			err = zerr.ErrBadHTTPStatusCode
   131  		}
   132  
   133  		bodyBytes, _ := io.ReadAll(resp.Body)
   134  
   135  		return nil, fmt.Errorf("%w: Expected: %d, Got: %d, Body: '%s'", err, http.StatusOK,
   136  			resp.StatusCode, string(bodyBytes))
   137  	}
   138  
   139  	if resultsPtr == nil {
   140  		return resp.Header, nil
   141  	}
   142  
   143  	if err := json.NewDecoder(resp.Body).Decode(resultsPtr); err != nil {
   144  		return nil, err
   145  	}
   146  
   147  	return resp.Header, nil
   148  }
   149  
   150  func validateURL(str string) error {
   151  	parsedURL, err := url.Parse(str)
   152  	if err != nil {
   153  		if strings.Contains(err.Error(), "first path segment in URL cannot contain colon") {
   154  			return fmt.Errorf("%w: scheme not provided (ex: https://)", zerr.ErrInvalidURL)
   155  		}
   156  
   157  		return err
   158  	}
   159  
   160  	if parsedURL.Scheme == "" || parsedURL.Host == "" {
   161  		return fmt.Errorf("%w: scheme not provided (ex: https://)", zerr.ErrInvalidURL)
   162  	}
   163  
   164  	return nil
   165  }
   166  
   167  type requestsPool struct {
   168  	jobs     chan *httpJob
   169  	done     chan struct{}
   170  	wtgrp    *sync.WaitGroup
   171  	outputCh chan stringResult
   172  }
   173  
   174  type httpJob struct {
   175  	url       string
   176  	username  string
   177  	password  string
   178  	imageName string
   179  	tagName   string
   180  	config    SearchConfig
   181  }
   182  
   183  const rateLimiterBuffer = 5000
   184  
   185  func newSmoothRateLimiter(wtgrp *sync.WaitGroup, opch chan stringResult) *requestsPool {
   186  	ch := make(chan *httpJob, rateLimiterBuffer)
   187  
   188  	return &requestsPool{
   189  		jobs:     ch,
   190  		done:     make(chan struct{}),
   191  		wtgrp:    wtgrp,
   192  		outputCh: opch,
   193  	}
   194  }
   195  
   196  // block every "rateLimit" time duration.
   197  const rateLimit = 100 * time.Millisecond
   198  
   199  func (p *requestsPool) startRateLimiter(ctx context.Context) {
   200  	p.wtgrp.Done()
   201  
   202  	throttle := time.NewTicker(rateLimit).C
   203  
   204  	for {
   205  		select {
   206  		case job := <-p.jobs:
   207  			go p.doJob(ctx, job)
   208  		case <-p.done:
   209  			return
   210  		}
   211  		<-throttle
   212  	}
   213  }
   214  
   215  func (p *requestsPool) doJob(ctx context.Context, job *httpJob) {
   216  	defer p.wtgrp.Done()
   217  
   218  	// Check manifest media type
   219  	header, err := makeHEADRequest(ctx, job.url, job.username, job.password, job.config.VerifyTLS,
   220  		job.config.Debug)
   221  	if err != nil {
   222  		if common.IsContextDone(ctx) {
   223  			return
   224  		}
   225  		p.outputCh <- stringResult{"", err}
   226  	}
   227  
   228  	verbose := job.config.Verbose
   229  
   230  	switch header.Get("Content-Type") {
   231  	case ispec.MediaTypeImageManifest:
   232  		image, err := fetchImageManifestStruct(ctx, job)
   233  		if err != nil {
   234  			if common.IsContextDone(ctx) {
   235  				return
   236  			}
   237  			p.outputCh <- stringResult{"", err}
   238  
   239  			return
   240  		}
   241  		platformStr := getPlatformStr(image.Manifests[0].Platform)
   242  
   243  		str, err := image.string(job.config.OutputFormat, len(job.imageName), len(job.tagName), len(platformStr), verbose)
   244  		if err != nil {
   245  			if common.IsContextDone(ctx) {
   246  				return
   247  			}
   248  			p.outputCh <- stringResult{"", err}
   249  
   250  			return
   251  		}
   252  
   253  		if common.IsContextDone(ctx) {
   254  			return
   255  		}
   256  
   257  		p.outputCh <- stringResult{str, nil}
   258  	case ispec.MediaTypeImageIndex:
   259  		image, err := fetchImageIndexStruct(ctx, job)
   260  		if err != nil {
   261  			if common.IsContextDone(ctx) {
   262  				return
   263  			}
   264  			p.outputCh <- stringResult{"", err}
   265  
   266  			return
   267  		}
   268  
   269  		platformStr := getPlatformStr(image.Manifests[0].Platform)
   270  
   271  		str, err := image.string(job.config.OutputFormat, len(job.imageName), len(job.tagName), len(platformStr), verbose)
   272  		if err != nil {
   273  			if common.IsContextDone(ctx) {
   274  				return
   275  			}
   276  			p.outputCh <- stringResult{"", err}
   277  
   278  			return
   279  		}
   280  
   281  		if common.IsContextDone(ctx) {
   282  			return
   283  		}
   284  
   285  		p.outputCh <- stringResult{str, nil}
   286  	default:
   287  		return
   288  	}
   289  }
   290  
   291  func fetchImageIndexStruct(ctx context.Context, job *httpJob) (*imageStruct, error) {
   292  	var indexContent ispec.Index
   293  
   294  	header, err := makeGETRequest(ctx, job.url, job.username, job.password,
   295  		job.config.VerifyTLS, job.config.Debug, &indexContent, job.config.ResultWriter)
   296  	if err != nil {
   297  		if common.IsContextDone(ctx) {
   298  			return nil, context.Canceled
   299  		}
   300  
   301  		return nil, err
   302  	}
   303  
   304  	indexDigest := header.Get("docker-content-digest")
   305  
   306  	indexSize, err := strconv.ParseInt(header.Get("Content-Length"), 10, 64)
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  
   311  	imageSize := indexSize
   312  
   313  	manifestList := make([]common.ManifestSummary, 0, len(indexContent.Manifests))
   314  
   315  	for _, manifestDescriptor := range indexContent.Manifests {
   316  		manifest, err := fetchManifestStruct(ctx, job.imageName, manifestDescriptor.Digest.String(),
   317  			job.config, job.username, job.password)
   318  		if err != nil {
   319  			return nil, err
   320  		}
   321  
   322  		imageSize += int64(atoiWithDefault(manifest.Size, 0))
   323  
   324  		if manifestDescriptor.Platform != nil {
   325  			manifest.Platform = common.Platform{
   326  				Os:      manifestDescriptor.Platform.OS,
   327  				Arch:    manifestDescriptor.Platform.Architecture,
   328  				Variant: manifestDescriptor.Platform.Variant,
   329  			}
   330  		}
   331  
   332  		manifestList = append(manifestList, manifest)
   333  	}
   334  
   335  	isIndexSigned := isCosignSigned(ctx, job.imageName, indexDigest, job.config, job.username, job.password) ||
   336  		isNotationSigned(ctx, job.imageName, indexDigest, job.config, job.username, job.password)
   337  
   338  	return &imageStruct{
   339  		RepoName:  job.imageName,
   340  		Tag:       job.tagName,
   341  		Digest:    indexDigest,
   342  		MediaType: ispec.MediaTypeImageIndex,
   343  		Manifests: manifestList,
   344  		Size:      strconv.FormatInt(imageSize, 10),
   345  		IsSigned:  isIndexSigned,
   346  	}, nil
   347  }
   348  
   349  func atoiWithDefault(size string, defaultVal int) int {
   350  	val, err := strconv.Atoi(size)
   351  	if err != nil {
   352  		return defaultVal
   353  	}
   354  
   355  	return val
   356  }
   357  
   358  func fetchImageManifestStruct(ctx context.Context, job *httpJob) (*imageStruct, error) {
   359  	manifest, err := fetchManifestStruct(ctx, job.imageName, job.tagName, job.config, job.username, job.password)
   360  	if err != nil {
   361  		return nil, err
   362  	}
   363  
   364  	return &imageStruct{
   365  		RepoName:  job.imageName,
   366  		Tag:       job.tagName,
   367  		Digest:    manifest.Digest,
   368  		MediaType: ispec.MediaTypeImageManifest,
   369  		Manifests: []common.ManifestSummary{
   370  			manifest,
   371  		},
   372  		Size:     manifest.Size,
   373  		IsSigned: manifest.IsSigned,
   374  	}, nil
   375  }
   376  
   377  func fetchManifestStruct(ctx context.Context, repo, manifestReference string, searchConf SearchConfig,
   378  	username, password string,
   379  ) (common.ManifestSummary, error) {
   380  	manifestResp := ispec.Manifest{}
   381  
   382  	URL := fmt.Sprintf("%s/v2/%s/manifests/%s",
   383  		searchConf.ServURL, repo, manifestReference)
   384  
   385  	header, err := makeGETRequest(ctx, URL, username, password,
   386  		searchConf.VerifyTLS, searchConf.Debug, &manifestResp, searchConf.ResultWriter)
   387  	if err != nil {
   388  		if common.IsContextDone(ctx) {
   389  			return common.ManifestSummary{}, context.Canceled
   390  		}
   391  
   392  		return common.ManifestSummary{}, err
   393  	}
   394  
   395  	manifestDigest := header.Get("docker-content-digest")
   396  	configDigest := manifestResp.Config.Digest.String()
   397  
   398  	configContent, err := fetchConfig(ctx, repo, configDigest, searchConf, username, password)
   399  	if err != nil {
   400  		if common.IsContextDone(ctx) {
   401  			return common.ManifestSummary{}, context.Canceled
   402  		}
   403  
   404  		return common.ManifestSummary{}, err
   405  	}
   406  
   407  	opSys := ""
   408  	arch := ""
   409  	variant := ""
   410  
   411  	if manifestResp.Config.Platform != nil {
   412  		opSys = manifestResp.Config.Platform.OS
   413  		arch = manifestResp.Config.Platform.Architecture
   414  		variant = manifestResp.Config.Platform.Variant
   415  	}
   416  
   417  	if opSys == "" {
   418  		opSys = configContent.OS
   419  	}
   420  
   421  	if arch == "" {
   422  		arch = configContent.Architecture
   423  	}
   424  
   425  	if variant == "" {
   426  		variant = configContent.Variant
   427  	}
   428  
   429  	manifestSize, err := strconv.ParseInt(header.Get("Content-Length"), 10, 64)
   430  	if err != nil {
   431  		return common.ManifestSummary{}, err
   432  	}
   433  
   434  	var imageSize int64
   435  
   436  	imageSize += manifestResp.Config.Size
   437  	imageSize += manifestSize
   438  
   439  	layers := []common.LayerSummary{}
   440  
   441  	for _, entry := range manifestResp.Layers {
   442  		imageSize += entry.Size
   443  
   444  		layers = append(
   445  			layers,
   446  			common.LayerSummary{
   447  				Size:   fmt.Sprintf("%v", entry.Size),
   448  				Digest: entry.Digest.String(),
   449  			},
   450  		)
   451  	}
   452  
   453  	isSigned := isCosignSigned(ctx, repo, manifestDigest, searchConf, username, password) ||
   454  		isNotationSigned(ctx, repo, manifestDigest, searchConf, username, password)
   455  
   456  	return common.ManifestSummary{
   457  		ConfigDigest: configDigest,
   458  		Digest:       manifestDigest,
   459  		Layers:       layers,
   460  		Platform:     common.Platform{Os: opSys, Arch: arch, Variant: variant},
   461  		Size:         strconv.FormatInt(imageSize, 10),
   462  		IsSigned:     isSigned,
   463  	}, nil
   464  }
   465  
   466  func fetchConfig(ctx context.Context, repo, configDigest string, searchConf SearchConfig,
   467  	username, password string,
   468  ) (ispec.Image, error) {
   469  	configContent := ispec.Image{}
   470  
   471  	URL := fmt.Sprintf("%s/v2/%s/blobs/%s",
   472  		searchConf.ServURL, repo, configDigest)
   473  
   474  	_, err := makeGETRequest(ctx, URL, username, password,
   475  		searchConf.VerifyTLS, searchConf.Debug, &configContent, searchConf.ResultWriter)
   476  	if err != nil {
   477  		if common.IsContextDone(ctx) {
   478  			return ispec.Image{}, context.Canceled
   479  		}
   480  
   481  		return ispec.Image{}, err
   482  	}
   483  
   484  	return configContent, nil
   485  }
   486  
   487  func isNotationSigned(ctx context.Context, repo, digestStr string, searchConf SearchConfig,
   488  	username, password string,
   489  ) bool {
   490  	var referrers ispec.Index
   491  
   492  	URL := fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s",
   493  		searchConf.ServURL, repo, digestStr, common.ArtifactTypeNotation)
   494  
   495  	_, err := makeGETRequest(ctx, URL, username, password,
   496  		searchConf.VerifyTLS, searchConf.Debug, &referrers, searchConf.ResultWriter)
   497  	if err != nil {
   498  		return false
   499  	}
   500  
   501  	if len(referrers.Manifests) > 0 {
   502  		return true
   503  	}
   504  
   505  	return false
   506  }
   507  
   508  func isCosignSigned(ctx context.Context, repo, digestStr string, searchConf SearchConfig,
   509  	username, password string,
   510  ) bool {
   511  	var result interface{}
   512  	cosignTag := strings.Replace(digestStr, ":", "-", 1) + "." + remote.SignatureTagSuffix
   513  
   514  	URL := fmt.Sprintf("%s/v2/%s/manifests/%s", searchConf.ServURL, repo, cosignTag)
   515  
   516  	_, err := makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS,
   517  		searchConf.Debug, &result, searchConf.ResultWriter)
   518  
   519  	if err == nil {
   520  		return true
   521  	}
   522  
   523  	var referrers ispec.Index
   524  
   525  	artifactType := url.QueryEscape(common.ArtifactTypeCosign)
   526  	URL = fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s",
   527  		searchConf.ServURL, repo, digestStr, artifactType)
   528  
   529  	_, err = makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS,
   530  		searchConf.Debug, &referrers, searchConf.ResultWriter)
   531  	if err != nil {
   532  		return false
   533  	}
   534  
   535  	if len(referrers.Manifests) == 0 {
   536  		return false
   537  	}
   538  
   539  	return true
   540  }
   541  
   542  func (p *requestsPool) submitJob(job *httpJob) {
   543  	p.jobs <- job
   544  }