Skip to content

Instantly share code, notes, and snippets.

@anonymouse64
Created November 18, 2020 02:30
Show Gist options
  • Save anonymouse64/ab77e07ed9ef5406164646ec9042f617 to your computer and use it in GitHub Desktop.
Save anonymouse64/ab77e07ed9ef5406164646ec9042f617 to your computer and use it in GitHub Desktop.
pointless overly complicated and overly engineered kernel cmdline parsing utilities using SplitFunc supporting quotes (but not nested quotes!) in Go
// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2020 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package osutil
import (
"bufio"
"bytes"
"fmt"
"io/ioutil"
"strings"
"unicode"
"unicode/utf8"
)
var (
procCmdline = "/proc/cmdline"
)
// scanWordsWithQuotes is a split function for a Scanner that returns each
// space-separated word of text, with surrounding spaces deleted. It also
// properly handles quotes, in that if there is an opening quote, then instead
// of space-separated words, quoted words are returned. If the quoted word is
// directly following non-spaces, then the whole word is returned, starting with
// the non-space characters, then the quote character, then the words within the
// quote, then the closing quote character. The quote character is either " or
// ', but must be consistent (i.e. an opening " will only be considered closed
// with another "). It does not support handling nested quotes of the same type,
// but does accept different quotes nested within such as in the string
// "this is 'nested'" will be returned as (verbatim) `"this is 'nested'"`.
func scanWordsWithQuotes(data []byte, atEOF bool) (advance int, token []byte, err error) {
// Skip leading spaces.
start := 0
for width := 0; start < len(data); start += width {
var r rune
r, width = utf8.DecodeRune(data[start:])
if !unicode.IsSpace(r) {
break
}
}
// handle the trivial case where we just have leading spaces in data
if len(data) <= start {
return start, nil, nil
}
// Scan until either a space (which is the end of an unquoted word) or a
// quote, which is the start of a quoted word. If we find an unquoted word,
// then we also
checkEndChar := unicode.IsSpace
startedQuote := false
finishedQuote := false
for width, i := 0, start; i < len(data); i += width {
var r rune
r, width = utf8.DecodeRune(data[i:])
if r == '\'' {
if !startedQuote {
// we found the start of a quote
startedQuote = true
// change the end condition to look for the '
checkEndChar = func(r rune) bool {
return r == '\''
}
// skip the end check since we need to at least get one more
// character to end the quote
continue
}
// otherwise we already started a quote and will have set
// checkEndChar appropriately
}
if r == '"' {
if !startedQuote {
// we found the start of a quote
startedQuote = true
// change the end condition to look for the "
checkEndChar = func(r rune) bool {
return r == '"'
}
// skip the end check since we need to at least get one more
// character to end the quote
continue
}
// otherwise we already started a quote and will have set
// checkEndChar appropriately
}
if checkEndChar(r) {
if startedQuote && !finishedQuote {
// then we finished the quote and should continue until we hit
// a space to indicate the end of the "word"
checkEndChar = unicode.IsSpace
finishedQuote = true
continue
}
return i + width, data[start:i], nil
}
}
if startedQuote {
// if we started a quote but are not yet at EOF, then request more data
if !atEOF {
return start, nil, nil
}
// if we did start a quote but are not yet at EOF, check if we
// finished the quote - if we did then we just return what we got
// this case is when the EOF happens at the end of a quote
if finishedQuote {
return len(data), data[start:], nil
}
// otherwise we are at EOF, but we didn't finish a quote, so we have an
// invalid input and we should completely error - the quoting is
// incomplete
return 0, nil, fmt.Errorf("cannot scan words: incomplete quoting")
}
// otherwise if we didn't start a quote, and we are at EOF we have a final,
// non-empty, non-terminated word. Return it.
if atEOF && len(data) > start {
return len(data), data[start:], nil
}
// Request more data.
return start, nil, nil
}
// deQuote will delete the first specified quote character and the last one in
// the provided string. If the provided string does not contain exactly 2
// of the provided quote character, then it does nothing and returns the string
// as-is.
// Suitable for use on text scanned with the SplitFunc scanWordsWithQuotes where
// the only valid words returned that contain quotes can only contain exactly 2
// quotes of any one type.
func deQuote(s string, quoteChar rune) string {
if strings.Count(s, string(quoteChar)) != 2 {
// just don't do anything, we can't de-quote something that doesn't have
// multiple quotes
return s
}
startIndex := strings.IndexRune(s, quoteChar)
// drop the first
newStart := s[:startIndex]
newEnd := s[startIndex+1:]
newS := newStart + newEnd
// now do it again for the second quote
secondIndex := strings.IndexRune(newS, quoteChar)
// drop it again
finalStart := newS[:secondIndex]
finalEnd := newS[secondIndex+1:]
return finalStart + finalEnd
}
// charNotBetweenOtherChars returns whether or not the first rune is in between two
// instances of the second rune.
func charNotBetweenOtherChars(s string, s1, s2 rune) bool {
inBetweenRuneCount := strings.Count(s, string(s2))
if inBetweenRuneCount == 1 || inBetweenRuneCount == 0 {
// can't be in between something if there's not at least 2 of it
return true
}
if inBetweenRuneCount > 2 {
// if there are more than 2 chars, we can't really say for sure, as it's
// unclear which one should be used as the, but the safer bet is that
// yes, there could be something fishy, so don't parse it as if there
// wasn't
return false
}
// otherwise we have two, and so we just make sure that the index of the
// first rune is greater than the first instance of the second rune, and the
// last instance of the second rune
firstInstanceFirstRune := strings.IndexRune(s, s1)
firstInstanceSecondRune := strings.IndexRune(s, s2)
secondInstanceSecondRune := strings.IndexRune(s[firstInstanceSecondRune:], s2)
return !(firstInstanceFirstRune > firstInstanceSecondRune && firstInstanceFirstRune < secondInstanceSecondRune)
}
// ParseKernelCommandLineParameters parses the kernel command line and separates
// the key value pairs (specifically, parameters that use "=" as the delimiter
// between the key and the value) from all non key value pair parameters and
// returns both sets of parameters.
// If there are duplicate key-value pairs, then the pair that appears last will
// be the one that appears in the map.
func ParseKernelCommandLineParameters() (map[string]string, []string, error) {
cmdline, err := ioutil.ReadFile(procCmdline)
if err != nil {
return nil, nil, err
}
scanner := bufio.NewScanner(bytes.NewBuffer(cmdline))
// handle quotes when scanning
scanner.Split(scanWordsWithQuotes)
var keyValParams map[string]string
var plainParams []string
for scanner.Scan() {
w := scanner.Text()
// if there are quote characters in the text, then it might be something
// we need to de-quote, but if there are multiple kinds of quotes, then
// don't try anything fancy
switch {
case strings.Contains(w, "'") && strings.Contains(w, "\"") && strings.Contains(w, "="):
// we need a more complicated state machine to parse/handle nested
// quotes, so just skip de-quoting this one and trying to figure out
// where the equals sign and the quotes live relative to one another
// TODO: maybe we should error out here to make clear to callers we
// don't support nested quoting?
plainParams = append(plainParams, w)
continue
// in other cases, if there's a quote and the quoted part is not
// containing a "=", then there must be a single pair of somewhere
// and we should de-quote it to be able to parse out the key/value
case strings.Contains(w, "'") && charNotBetweenOtherChars(w, '=', '\''):
w = deQuote(w, '\'')
case strings.Contains(w, "\"") && charNotBetweenOtherChars(w, '=', '"'):
w = deQuote(w, '"')
// otherwise don't de-quote it
}
if strings.Contains(w, "=") {
pair := strings.SplitN(w, "=", 2)
// this has the side-effect that later keys override earlier ones
if keyValParams == nil {
keyValParams = make(map[string]string, 1)
}
keyValParams[pair[0]] = pair[1]
} else {
plainParams = append(plainParams, w)
}
}
// be robust and still return what we accumulated, some callers may not care
// if the end of the cmd line is malformed, as long as they find what
// they care about before that point
return keyValParams, plainParams, scanner.Err()
}
// MockProcCmdline overrides the path to /proc/cmdline. For use in tests.
func MockProcCmdline(newPath string) (restore func()) {
oldProcCmdline := procCmdline
procCmdline = newPath
return func() {
procCmdline = oldProcCmdline
}
}
// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2020 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package osutil_test
import (
"io/ioutil"
"path/filepath"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/testutil"
. "gopkg.in/check.v1"
)
type kernelCmdlineSuite struct {
testutil.BaseTest
}
var _ = Suite(&kernelCmdlineSuite{})
func (s *kernelCmdlineSuite) SetUpTest(c *C) {
rootDir := c.MkDir()
dirs.SetRootDir(rootDir)
s.AddCleanup(func() { dirs.SetRootDir("") })
}
func (s *kernelCmdlineSuite) TestKernelCommandline(c *C) {
tt := []struct {
procCmdline string
expPlain []string
expMap map[string]string
expErr string
comment string
}{
{
procCmdline: "",
comment: "empty",
},
{
procCmdline: "\"\"",
comment: "empty quotes is empty string",
expPlain: []string{
"",
},
},
{
procCmdline: `one two ""`,
comment: "empty quote is empty string with more before",
expPlain: []string{
"one",
"two",
"",
},
},
{
procCmdline: `one two "" another`,
comment: "empty quote is empty string with more before and after",
expPlain: []string{
"one",
"two",
"",
"another",
},
},
{
procCmdline: "key1=val1 key2=val2",
comment: "only key-val pairs",
expMap: map[string]string{
"key1": "val1",
"key2": "val2",
},
},
{
procCmdline: "empty-key=",
comment: "empty key value",
expMap: map[string]string{
"empty-key": "",
},
},
{
procCmdline: "before=something systemd.silliness=a=1 others=2",
comment: "multiple equals in value",
expMap: map[string]string{
"before": "something",
"systemd.silliness": "a=1",
"others": "2",
},
},
{
procCmdline: "key=val init 0 key2=other more stuff",
comment: "key value pairs and plain params",
expMap: map[string]string{
"key": "val",
"key2": "other",
},
expPlain: []string{
"init",
"0",
"more",
"stuff",
},
},
{
procCmdline: `"key=val" key2="val2"`,
comment: "quoted key values",
expMap: map[string]string{
"key": "val",
"key2": "val2",
},
},
{
procCmdline: `"key=val" key2="val2"`,
comment: "quoting just values",
expMap: map[string]string{
"key": "val",
"key2": "val2",
},
},
{
procCmdline: `"key=val" key2="this is a nested 'quote'"`,
comment: "nested quoted key values are ignored",
expMap: map[string]string{
"key": "val",
},
expPlain: []string{
`key2="this is a nested 'quote'"`,
},
},
{
procCmdline: `"key=val" init 0 key2=other "this is a long quote"`,
comment: "quoted plain values",
expMap: map[string]string{
"key": "val",
"key2": "other",
},
expPlain: []string{
"init",
"0",
`this is a long quote`,
},
},
{
procCmdline: `1"2"3`,
comment: "weird sandwiched quotes",
expPlain: []string{
`123`,
},
},
{
procCmdline: `bread" with peanut butter and "jelly`,
comment: "weird sandwiched quotes with spaces",
expPlain: []string{
`bread with peanut butter and jelly`,
},
},
{
procCmdline: `incomplete quoting"`,
comment: "incomplete quoting only fails last word",
expPlain: []string{
"incomplete",
},
expErr: "cannot scan words: incomplete quoting",
},
{
procCmdline: `complete quoting""`,
comment: "empty quote at end of word stripped off",
expPlain: []string{
"complete",
"quoting",
},
},
{
procCmdline: `'single quotes'`,
comment: "single quotes plain",
expPlain: []string{
"single quotes",
},
},
{
procCmdline: `k1='single quotes'`,
comment: "single quotes key value pairs",
expMap: map[string]string{
"k1": "single quotes",
},
},
}
for _, t := range tt {
comment := Commentf(t.comment)
new := filepath.Join(c.MkDir(), "cmdline")
c.Assert(ioutil.WriteFile(new, []byte(t.procCmdline), 0644), IsNil, comment)
r := osutil.MockProcCmdline(new)
defer r()
m, plain, err := osutil.ParseKernelCommandLineParameters()
if t.expErr != "" {
c.Assert(err, ErrorMatches, t.expErr, comment)
} else {
c.Assert(err, IsNil, comment)
}
c.Assert(m, DeepEquals, t.expMap, comment)
c.Assert(plain, DeepEquals, t.expPlain, comment)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment