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  }