github.com/quay/claircore@v1.5.28/ruby/packagescanner.go (about)

     1  // Package ruby contains components for interrogating ruby packages in
     2  // container layers.
     3  package ruby
     4  
     5  import (
     6  	"bufio"
     7  	"context"
     8  	"fmt"
     9  	"io/fs"
    10  	"path/filepath"
    11  	"regexp"
    12  	"runtime/trace"
    13  	"strings"
    14  
    15  	"github.com/quay/zlog"
    16  
    17  	"github.com/quay/claircore"
    18  	"github.com/quay/claircore/indexer"
    19  )
    20  
    21  var (
    22  	gemspecPath = regexp.MustCompile(`.*/specifications/.+\.gemspec`)
    23  
    24  	// Example gemspec:
    25  	//
    26  	// Gem::Specification.new do |s|
    27  	//   s.name        = 'example'
    28  	//   s.version     = '0.1.0'
    29  	//   s.licenses    = ['MIT']
    30  	//   s.summary     = "This is an example!"
    31  	//   s.description = "Much longer explanation of the example!"
    32  	//   s.authors     = ["Ruby Coder"]
    33  	//   s.email       = 'rubycoder@example.com'
    34  	//   s.files       = ["lib/example.rb"]
    35  	//   s.homepage    = 'https://rubygems.org/gems/example'
    36  	//   s.metadata    = { "source_code_uri" => "https://github.com/example/example" }
    37  	// end
    38  	nameLine    = regexp.MustCompile(`^\S+\.\s*name\s*=\s*(?P<name>\S+)$`)
    39  	versionLine = regexp.MustCompile(`^\S+\.\s*version\s*=\s*(?P<version>\S+)$`)
    40  )
    41  
    42  const (
    43  	nameIdx    = 1
    44  	versionIdx = 1
    45  
    46  	repository = "rubygems"
    47  )
    48  
    49  var (
    50  	_ indexer.VersionedScanner   = (*Scanner)(nil)
    51  	_ indexer.PackageScanner     = (*Scanner)(nil)
    52  	_ indexer.DefaultRepoScanner = (*Scanner)(nil)
    53  
    54  	Repository = claircore.Repository{
    55  		Name: repository,
    56  		URI:  "https://rubygems.org/gems/",
    57  	}
    58  )
    59  
    60  // Scanner implements the scanner.PackageScanner interface.
    61  //
    62  // It looks for files that seem like gems, and looks at the
    63  // metadata recorded there. This type attempts to follow the specs documented
    64  // here: https://guides.rubygems.org/specification-reference/.
    65  //
    66  // The zero value is ready to use.
    67  type Scanner struct{}
    68  
    69  // Name implements scanner.VersionedScanner.
    70  func (*Scanner) Name() string { return "ruby" }
    71  
    72  // Version implements scanner.VersionedScanner.
    73  func (*Scanner) Version() string { return "2" }
    74  
    75  // Kind implements scanner.VersionedScanner.
    76  func (*Scanner) Kind() string { return "package" }
    77  
    78  // Scan attempts to find gems and record the package information there.
    79  //
    80  // A return of (nil, nil) is expected if there's nothing found.
    81  func (ps *Scanner) Scan(ctx context.Context, layer *claircore.Layer) ([]*claircore.Package, error) {
    82  	defer trace.StartRegion(ctx, "Scanner.Scan").End()
    83  	trace.Log(ctx, "layer", layer.Hash.String())
    84  	ctx = zlog.ContextWithValues(ctx,
    85  		"component", "ruby/Scanner.Scan",
    86  		"version", ps.Version(),
    87  		"layer", layer.Hash.String())
    88  	zlog.Debug(ctx).Msg("start")
    89  	defer zlog.Debug(ctx).Msg("done")
    90  	if err := ctx.Err(); err != nil {
    91  		return nil, err
    92  	}
    93  
    94  	sys, err := layer.FS()
    95  	if err != nil {
    96  		return nil, fmt.Errorf("ruby: unable to open layer: %w", err)
    97  	}
    98  
    99  	gs, err := gems(ctx, sys)
   100  	if err != nil {
   101  		return nil, fmt.Errorf("ruby: failed to find packages: %w", err)
   102  	}
   103  
   104  	var ret []*claircore.Package
   105  	for _, g := range gs {
   106  		f, err := sys.Open(g)
   107  		if err != nil {
   108  			return nil, fmt.Errorf("ruby: unable to open file: %w", err)
   109  		}
   110  
   111  		var name, version string
   112  
   113  		scanner := bufio.NewScanner(f)
   114  		for scanner.Scan() {
   115  			line := strings.TrimSpace(scanner.Text())
   116  			if matches := nameLine.FindStringSubmatch(line); matches != nil {
   117  				name = trim(matches[nameIdx])
   118  			}
   119  			if matches := versionLine.FindStringSubmatch(line); matches != nil {
   120  				version = trim(matches[versionIdx])
   121  			}
   122  		}
   123  		if err := scanner.Err(); err != nil {
   124  			zlog.Warn(ctx).
   125  				Err(err).
   126  				Str("path", g).
   127  				Msg("unable to read metadata, skipping")
   128  			continue
   129  		}
   130  
   131  		if name == "" || version == "" {
   132  			zlog.Warn(ctx).
   133  				Str("path", g).
   134  				Msg("couldn't parse name or version, skipping")
   135  			continue
   136  		}
   137  
   138  		ret = append(ret, &claircore.Package{
   139  			Name:           name,
   140  			Version:        version,
   141  			Kind:           claircore.BINARY,
   142  			PackageDB:      "ruby:" + g,
   143  			Filepath:       g,
   144  			RepositoryHint: repository,
   145  		})
   146  	}
   147  
   148  	return ret, nil
   149  }
   150  
   151  // DefaultRepository implements [indexer.DefaultRepoScanner].
   152  func (Scanner) DefaultRepository(ctx context.Context) *claircore.Repository {
   153  	return &Repository
   154  }
   155  
   156  func trim(s string) string {
   157  	s = strings.TrimSpace(s)
   158  	s = strings.TrimSuffix(s, `.freeze`)
   159  	return strings.Trim(s, `'"`)
   160  }
   161  
   162  func gems(ctx context.Context, sys fs.FS) (out []string, err error) {
   163  	return out, fs.WalkDir(sys, ".", func(p string, d fs.DirEntry, err error) error {
   164  		ev := zlog.Debug(ctx).
   165  			Str("file", p)
   166  		var success bool
   167  		defer func() {
   168  			if !success {
   169  				ev.Discard().Send()
   170  			}
   171  		}()
   172  		switch {
   173  		case err != nil:
   174  			return err
   175  		case !d.Type().IsRegular():
   176  			// Should we chase symlinks with the correct name?
   177  			return nil
   178  		case strings.HasPrefix(filepath.Base(p), ".wh."):
   179  			return nil
   180  		case gemspecPath.MatchString(p):
   181  			ev = ev.Str("kind", "gem")
   182  		default:
   183  			return nil
   184  		}
   185  		ev.Msg("found package")
   186  		success = true
   187  		out = append(out, p)
   188  		return nil
   189  	})
   190  }