github.com/u-root/u-root@v7.0.1-0.20200915234505-ad7babab0a8e+incompatible/pkg/boot/grub/grub.go (about) 1 // Copyright 2017-2020 the u-root Authors. All rights reserved 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package grub implements a grub config file parser. 6 // 7 // See the grub manual https://www.gnu.org/software/grub/manual/grub/ for 8 // a reference of the configuration format 9 // In particular the following pages: 10 // - https://www.gnu.org/software/grub/manual/grub/html_node/Shell_002dlike-scripting.html 11 // - https://www.gnu.org/software/grub/manual/grub/html_node/Commands.html 12 // 13 // See parser.append function for list of commands that are supported. 14 package grub 15 16 import ( 17 "context" 18 "fmt" 19 "io" 20 "log" 21 "net/url" 22 "path/filepath" 23 "regexp" 24 "strconv" 25 "strings" 26 27 "github.com/u-root/u-root/pkg/boot" 28 "github.com/u-root/u-root/pkg/boot/multiboot" 29 "github.com/u-root/u-root/pkg/curl" 30 "github.com/u-root/u-root/pkg/shlex" 31 "github.com/u-root/u-root/pkg/uio" 32 ) 33 34 var probeGrubFiles = []string{ 35 "boot/grub/grub.cfg", 36 "grub/grub.cfg", 37 "grub2/grub.cfg", 38 "boot/grub2/grub.cfg", 39 } 40 41 // Grub syntax for OpenSUSE/Fedora/RHEL has some undocumented quirks. You 42 // won't find it on the master branch, but instead look at the rhel and fedora 43 // branches for these commits: 44 // 45 // * https://github.com/rhboot/grub2/commit/7e6775e6d4a8de9baf3f4676d4e021cc2f5dd761 46 // * https://github.com/rhboot/grub2/commit/0c26c6f7525737962d1389ebdfbb918f52d1b3b6 47 // 48 // They add a special case to not escape hex sequences: 49 // 50 // grub> echo hello \xff \xfg 51 // hello \xff xfg 52 // 53 // Their default installations depend on this functionality. 54 var hexEscape = regexp.MustCompile(`\\x[0-9a-fA-F]{2}`) 55 var anyEscape = regexp.MustCompile(`\\.{0,3}`) 56 57 // ParseLocalConfig looks for a GRUB config in the disk partition mounted at 58 // diskDir and parses out OSes to boot. 59 // 60 // This... is at best crude, at worst totally wrong, since we fundamentally 61 // assume that the kernels we boot are only on this one partition. But so is 62 // this whole parser. 63 func ParseLocalConfig(ctx context.Context, diskDir string) ([]boot.OSImage, error) { 64 wd := &url.URL{ 65 Scheme: "file", 66 Path: diskDir, 67 } 68 69 // This is a hack. GRUB should stop caring about URLs at least in the 70 // way we use them today, because GRUB has additional path encoding 71 // methods. Sorry. 72 // 73 // Normally, stuff like this will be in EFI/BOOT/grub.cfg, but some 74 // distro's have their own directory in this EFI namespace. Just check 75 // 'em all. 76 files, err := filepath.Glob(filepath.Join(diskDir, "EFI", "*", "grub.cfg")) 77 if err != nil { 78 log.Printf("[grub] Could not glob for %s/EFI/*/grub.cfg: %v", diskDir, err) 79 } 80 var relNames []string 81 for _, file := range files { 82 base, err := filepath.Rel(diskDir, file) 83 if err == nil { 84 relNames = append(relNames, base) 85 } 86 } 87 88 for _, relname := range append(relNames, probeGrubFiles...) { 89 c, err := ParseConfigFile(ctx, curl.DefaultSchemes, relname, wd) 90 if curl.IsURLError(err) { 91 continue 92 } 93 return c, err 94 } 95 return nil, fmt.Errorf("no valid grub config found") 96 } 97 98 // ParseConfigFile parses a grub configuration as specified in 99 // https://www.gnu.org/software/grub/manual/grub/ 100 // 101 // Currently, only the linux[16|efi], initrd[16|efi], menuentry and set 102 // directives are partially supported. 103 // 104 // `wd` is the default scheme, host, and path for any files named as a 105 // relative path - e.g. kernel, include, and initramfs paths are requested 106 // relative to the wd. 107 func ParseConfigFile(ctx context.Context, s curl.Schemes, configFile string, wd *url.URL) ([]boot.OSImage, error) { 108 p := newParser(wd, s) 109 if err := p.appendFile(ctx, configFile); err != nil { 110 return nil, err 111 } 112 113 // Don't add entries twice. 114 // 115 // Multiple labels can refer to the same image, so we have to dedup by pointer. 116 seenLinux := make(map[*boot.LinuxImage]struct{}) 117 seenMB := make(map[*boot.MultibootImage]struct{}) 118 119 if len(p.defaultEntry) > 0 { 120 p.labelOrder = append([]string{p.defaultEntry}, p.labelOrder...) 121 } 122 123 var images []boot.OSImage 124 for _, label := range p.labelOrder { 125 if img, ok := p.linuxEntries[label]; ok { 126 if _, ok := seenLinux[img]; !ok { 127 images = append(images, img) 128 seenLinux[img] = struct{}{} 129 } 130 } 131 132 if img, ok := p.mbEntries[label]; ok { 133 if _, ok := seenMB[img]; !ok { 134 images = append(images, img) 135 seenMB[img] = struct{}{} 136 } 137 } 138 } 139 return images, nil 140 } 141 142 type parser struct { 143 linuxEntries map[string]*boot.LinuxImage 144 mbEntries map[string]*boot.MultibootImage 145 146 labelOrder []string 147 defaultEntry string 148 149 W io.Writer 150 151 // parser internals. 152 numEntry int 153 154 // curEntry is the current entry number as a string. 155 curEntry string 156 157 // curLabel is the last parsed label from a "menuentry". 158 curLabel string 159 160 wd *url.URL 161 schemes curl.Schemes 162 } 163 164 // newParser returns a new grub parser using working directory `wd` 165 // and schemes `s`. 166 // 167 // If a path encountered in a configuration file is relative instead of a full 168 // URL, `wd` is used as the "working directory" of that relative path; the 169 // resulting URL is roughly `wd.String()/path`. 170 // 171 // `s` is used to get files referred to by URLs. 172 func newParser(wd *url.URL, s curl.Schemes) *parser { 173 return &parser{ 174 linuxEntries: make(map[string]*boot.LinuxImage), 175 mbEntries: make(map[string]*boot.MultibootImage), 176 wd: wd, 177 schemes: s, 178 } 179 } 180 181 func parseURL(surl string, wd *url.URL) (*url.URL, error) { 182 u, err := url.Parse(surl) 183 if err != nil { 184 return nil, fmt.Errorf("could not parse URL %q: %v", surl, err) 185 } 186 187 if len(u.Scheme) == 0 { 188 u.Scheme = wd.Scheme 189 190 if len(u.Host) == 0 { 191 // If this is not there, it was likely just a path. 192 u.Host = wd.Host 193 u.Path = filepath.Join(wd.Path, filepath.Clean(u.Path)) 194 } 195 } 196 return u, nil 197 } 198 199 // getFile parses `url` relative to the config's working directory and returns 200 // an io.Reader for the requested url. 201 // 202 // If url is just a relative path and not a full URL, c.wd is used as the 203 // "working directory" of that relative path; the resulting URL is roughly 204 // path.Join(wd.String(), url). 205 func (c *parser) getFile(url string) (io.ReaderAt, error) { 206 u, err := parseURL(url, c.wd) 207 if err != nil { 208 return nil, err 209 } 210 211 return c.schemes.LazyFetch(u) 212 } 213 214 // appendFile parses the config file downloaded from `url` and adds it to `c`. 215 func (c *parser) appendFile(ctx context.Context, url string) error { 216 u, err := parseURL(url, c.wd) 217 if err != nil { 218 return err 219 } 220 221 r, err := c.schemes.Fetch(ctx, u) 222 if err != nil { 223 return err 224 } 225 226 config, err := uio.ReadAll(r) 227 if err != nil { 228 return err 229 } 230 if len(config) > 500 { 231 // Avoid flooding the console on real systems 232 // TODO: do we want to pass a verbose flag or a logger? 233 log.Printf("[grub] Got config file %s", r) 234 } else { 235 log.Printf("[grub] Got config file %s:\n%s\n", r, string(config)) 236 } 237 return c.append(ctx, string(config)) 238 } 239 240 // CmdlineQuote quotes the command line as grub-core/lib/cmdline.c does 241 func cmdlineQuote(args []string) string { 242 q := make([]string, len(args)) 243 for i, s := range args { 244 // Replace \ with \\ unless it matches \xXX 245 s = anyEscape.ReplaceAllStringFunc(s, func(match string) string { 246 if hexEscape.MatchString(match) { 247 return match 248 } 249 return strings.Replace(match, `\`, `\\`, -1) 250 }) 251 s = strings.Replace(s, `'`, `\'`, -1) 252 s = strings.Replace(s, `"`, `\"`, -1) 253 if strings.ContainsRune(s, ' ') { 254 s = `"` + s + `"` 255 } 256 q[i] = s 257 } 258 return strings.Join(q, " ") 259 } 260 261 // append parses `config` and adds the respective configuration to `c`. 262 // 263 // NOTE: This parser has outlived its usefulness already, given that it doesn't 264 // even understand the {} scoping in GRUB. But let's get the tests to pass, and 265 // then we can do a rewrite. 266 func (c *parser) append(ctx context.Context, config string) error { 267 // Here's a shitty parser. 268 for _, line := range strings.Split(config, "\n") { 269 // Add extra backslash for OpenSUSE/Fedora/RHEL use case. shlex 270 // will convert it back to a single backslash. 271 line = hexEscape.ReplaceAllString(line, `\\$0`) 272 kv := shlex.Argv(line) 273 if len(kv) < 1 { 274 continue 275 } 276 directive := strings.ToLower(kv[0]) 277 // Used by tests (allow no parameters here) 278 if c.W != nil && directive == "echo" { 279 fmt.Fprintf(c.W, "echo:%#v\n", kv[1:]) 280 } 281 282 if len(kv) <= 1 { 283 continue 284 } 285 arg := kv[1] 286 287 switch directive { 288 case "set": 289 vals := strings.SplitN(arg, "=", 2) 290 if len(vals) == 2 { 291 //TODO handle vars? bootVars[vals[0]] = vals[1] 292 //log.Printf("grubvar: %s=%s", vals[0], vals[1]) 293 if vals[0] == "default" { 294 c.defaultEntry = vals[1] 295 } 296 } 297 298 case "configfile": 299 // TODO test that 300 if err := c.appendFile(ctx, arg); err != nil { 301 return err 302 } 303 304 case "menuentry": 305 c.curEntry = strconv.Itoa(c.numEntry) 306 c.curLabel = arg 307 c.numEntry++ 308 c.labelOrder = append(c.labelOrder, c.curEntry, c.curLabel) 309 310 case "linux", "linux16", "linuxefi": 311 k, err := c.getFile(arg) 312 if err != nil { 313 return err 314 } 315 // from grub manual: "Any initrd must be reloaded after using this command" so we can replace the entry 316 entry := &boot.LinuxImage{ 317 Name: c.curLabel, 318 Kernel: k, 319 Cmdline: cmdlineQuote(kv[2:]), 320 } 321 c.linuxEntries[c.curEntry] = entry 322 c.linuxEntries[c.curLabel] = entry 323 324 case "initrd", "initrd16", "initrdefi": 325 if e, ok := c.linuxEntries[c.curEntry]; ok { 326 i, err := c.getFile(arg) 327 if err != nil { 328 return err 329 } 330 e.Initrd = i 331 } 332 333 case "multiboot": 334 // TODO handle --quirk-* arguments ? (change parsing) 335 k, err := c.getFile(arg) 336 if err != nil { 337 return err 338 } 339 // from grub manual: "Any initrd must be reloaded after using this command" so we can replace the entry 340 entry := &boot.MultibootImage{ 341 Name: c.curLabel, 342 Kernel: k, 343 Cmdline: cmdlineQuote(kv[2:]), 344 } 345 c.mbEntries[c.curEntry] = entry 346 c.mbEntries[c.curLabel] = entry 347 348 case "module": 349 // TODO handle --nounzip arguments ? (change parsing) 350 if e, ok := c.mbEntries[c.curEntry]; ok { 351 // The only allowed arg 352 cmdline := kv[1:] 353 if arg == "--nounzip" { 354 arg = kv[2] 355 cmdline = kv[2:] 356 } 357 358 m, err := c.getFile(arg) 359 if err != nil { 360 return err 361 } 362 // TODO: Lasy tryGzipFilter(m) 363 mod := multiboot.Module{ 364 Module: m, 365 Cmdline: cmdlineQuote(cmdline), 366 } 367 e.Modules = append(e.Modules, mod) 368 } 369 } 370 } 371 return nil 372 373 }