github.com/quay/claircore@v1.5.28/osrelease/scanner.go (about)

     1  // Package osrelease provides an "os-release" distribution scanner.
     2  package osrelease
     3  
     4  import (
     5  	"bufio"
     6  	"bytes"
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"runtime/trace"
    11  	"sort"
    12  	"strings"
    13  
    14  	"github.com/quay/claircore/toolkit/types/cpe"
    15  	"github.com/quay/zlog"
    16  
    17  	"github.com/quay/claircore"
    18  	"github.com/quay/claircore/indexer"
    19  )
    20  
    21  const (
    22  	scannerName    = "os-release"
    23  	scannerVersion = "2"
    24  	scannerKind    = "distribution"
    25  )
    26  
    27  // Path and FallbackPath are the two documented locations for the os-release
    28  // file. The latter should be consulted only if the former does not exist.
    29  const (
    30  	Path         = `etc/os-release`
    31  	FallbackPath = `usr/lib/os-release`
    32  )
    33  
    34  var (
    35  	_ indexer.DistributionScanner = (*Scanner)(nil)
    36  	_ indexer.VersionedScanner    = (*Scanner)(nil)
    37  )
    38  
    39  // Scanner implements a [indexer.DistributionScanner] that examines os-release
    40  // files, as documented at
    41  // https://www.freedesktop.org/software/systemd/man/os-release.html
    42  type Scanner struct{}
    43  
    44  // Name implements [indexer.VersionedScanner].
    45  func (*Scanner) Name() string { return scannerName }
    46  
    47  // Version implements [indexer.VersionedScanner].
    48  func (*Scanner) Version() string { return scannerVersion }
    49  
    50  // Kind implements [indexer.VersionedScanner].
    51  func (*Scanner) Kind() string { return scannerKind }
    52  
    53  // Scan reports any found os-release distribution information in the provided
    54  // layer.
    55  //
    56  // It's an expected outcome to return (nil, nil) when the os-release file is not
    57  // present in the layer.
    58  func (s *Scanner) Scan(ctx context.Context, l *claircore.Layer) ([]*claircore.Distribution, error) {
    59  	defer trace.StartRegion(ctx, "Scanner.Scan").End()
    60  	ctx = zlog.ContextWithValues(ctx,
    61  		"component", "osrelease/Scanner.Scan",
    62  		"version", s.Version(),
    63  		"layer", l.Hash.String())
    64  	zlog.Debug(ctx).Msg("start")
    65  	defer zlog.Debug(ctx).Msg("done")
    66  
    67  	sys, err := l.FS()
    68  	if err != nil {
    69  		return nil, fmt.Errorf("osrelease: unable to open layer: %w", err)
    70  	}
    71  
    72  	// Attempt to parse each os-release file encountered. On a successful parse,
    73  	// return the distribution.
    74  	var rd io.Reader
    75  	for _, n := range []string{Path, FallbackPath} {
    76  		f, err := sys.Open(n)
    77  		if err != nil {
    78  			zlog.Debug(ctx).
    79  				Str("name", n).
    80  				Err(err).
    81  				Msg("unable to open file")
    82  			continue
    83  		}
    84  		defer f.Close()
    85  		rd = f
    86  		break
    87  	}
    88  	if rd == nil {
    89  		zlog.Debug(ctx).Msg("didn't find an os-release file")
    90  		return nil, nil
    91  	}
    92  	d, err := toDist(ctx, rd)
    93  	if err != nil {
    94  		return nil, err
    95  	}
    96  	return []*claircore.Distribution{d}, nil
    97  }
    98  
    99  // ToDist returns the distribution information from the file contents provided on r.
   100  func toDist(ctx context.Context, r io.Reader) (*claircore.Distribution, error) {
   101  	ctx = zlog.ContextWithValues(ctx,
   102  		"component", "osrelease/parse")
   103  	defer trace.StartRegion(ctx, "parse").End()
   104  	m, err := Parse(ctx, r)
   105  	if err != nil {
   106  		return nil, err
   107  	}
   108  	d := claircore.Distribution{
   109  		Name: "Linux",
   110  		DID:  "linux",
   111  	}
   112  	ks := make([]string, 0, len(m))
   113  	for key := range m {
   114  		ks = append(ks, key)
   115  	}
   116  	sort.Strings(ks)
   117  	for _, key := range ks {
   118  		value := m[key]
   119  		switch key {
   120  		case "ID":
   121  			zlog.Debug(ctx).Msg("found ID")
   122  			d.DID = value
   123  		case "VERSION_ID":
   124  			zlog.Debug(ctx).Msg("found VERSION_ID")
   125  			d.VersionID = value
   126  		case "BUILD_ID":
   127  		case "VARIANT_ID":
   128  		case "CPE_NAME":
   129  			zlog.Debug(ctx).Msg("found CPE_NAME")
   130  			wfn, err := cpe.Unbind(value)
   131  			if err != nil {
   132  				zlog.Warn(ctx).
   133  					Err(err).
   134  					Str("value", value).
   135  					Msg("failed to unbind the cpe")
   136  				break
   137  			}
   138  			d.CPE = wfn
   139  		case "NAME":
   140  			zlog.Debug(ctx).Msg("found NAME")
   141  			d.Name = value
   142  		case "VERSION":
   143  			zlog.Debug(ctx).Msg("found VERSION")
   144  			d.Version = value
   145  		case "ID_LIKE":
   146  		case "VERSION_CODENAME":
   147  			zlog.Debug(ctx).Msg("found VERISON_CODENAME")
   148  			d.VersionCodeName = value
   149  		case "PRETTY_NAME":
   150  			zlog.Debug(ctx).Msg("found PRETTY_NAME")
   151  			d.PrettyName = value
   152  		case "REDHAT_BUGZILLA_PRODUCT":
   153  			zlog.Debug(ctx).Msg("using RHEL hack")
   154  			// This is a dirty hack because the Red Hat OVAL database and the
   155  			// CPE contained in the os-release file don't agree.
   156  			d.PrettyName = value
   157  		}
   158  	}
   159  	zlog.Debug(ctx).Str("name", d.Name).Msg("found dist")
   160  	return &d, nil
   161  }
   162  
   163  // Parse splits the contents of "r" into key-value pairs as described in
   164  // os-release(5).
   165  //
   166  // See comments in the source for edge cases.
   167  func Parse(ctx context.Context, r io.Reader) (map[string]string, error) {
   168  	ctx = zlog.ContextWithValues(ctx, "component", "osrelease/Parse")
   169  	defer trace.StartRegion(ctx, "Parse").End()
   170  	m := make(map[string]string)
   171  	s := bufio.NewScanner(r)
   172  	s.Split(bufio.ScanLines)
   173  	for s.Scan() && ctx.Err() == nil {
   174  		b := bytes.TrimSpace(s.Bytes())
   175  		switch {
   176  		case len(b) == 0:
   177  			continue
   178  		case b[0] == '#':
   179  			continue
   180  		}
   181  		eq := bytes.IndexRune(b, '=')
   182  		if eq == -1 {
   183  			return nil, fmt.Errorf("osrelease: malformed line %q", s.Text())
   184  		}
   185  		// Also handling spaces here matches what systemd seems to do.
   186  		key := strings.TrimSpace(string(b[:eq]))
   187  		value := strings.TrimSpace(string(b[eq+1:]))
   188  
   189  		// The value side is defined to follow shell-like quoting rules, which I
   190  		// take to mean:
   191  		//
   192  		// - Within single quotes, no characters are special, and escaping is
   193  		//   not possible. The only special case that needs to be handled is
   194  		//   getting a single quote, which is done in shell by ending the
   195  		//   string, escaping a single quote, then starting a new string.
   196  		//
   197  		// - Within double quotes, single quotes are not special, but double
   198  		//   quotes and a handful of other characters are, and almost the entire
   199  		//   lower-case ASCII alphabet can be escaped to produce various
   200  		//   codepoints.
   201  		//
   202  		// With these in mind, the arms of the switch below implement the first
   203  		// case and a limited version of the second.
   204  		switch value[0] {
   205  		case '\'':
   206  			value = strings.TrimFunc(value, func(r rune) bool { return r == '\'' })
   207  			value = strings.ReplaceAll(value, `'\''`, `'`)
   208  		case '"':
   209  			// This only implements the metacharacters that are called out in
   210  			// the os-release documentation.
   211  			value = strings.TrimFunc(value, func(r rune) bool { return r == '"' })
   212  			value = dqReplacer.Replace(value)
   213  		default:
   214  		}
   215  
   216  		m[key] = value
   217  	}
   218  	if err := s.Err(); err != nil {
   219  		return nil, err
   220  	}
   221  	if err := ctx.Err(); err != nil {
   222  		return nil, err
   223  	}
   224  	return m, nil
   225  }
   226  
   227  var dqReplacer = strings.NewReplacer(
   228  	"\\`", "`",
   229  	`\\`, `\`,
   230  	`\"`, `"`,
   231  	`\$`, `$`,
   232  )