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 )