Add alpha SSH config parser
The error handling is nonexistent and there's no easy way to get data out. But we can parse a SSH config file into a Go struct, and roundtrip that struct back to a file that looks (roughly) the same.
This commit is contained in:
151
config.go
Normal file
151
config.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package ssh_config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func User(hostname string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type ConfigFinder struct {
|
||||
IgnoreSystemConfig bool
|
||||
IgnoreUserConfig bool
|
||||
}
|
||||
|
||||
func (c *ConfigFinder) User(hostname string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var DefaultFinder = &ConfigFinder{IgnoreSystemConfig: false, IgnoreUserConfig: false}
|
||||
|
||||
func parseFile(filename string) (*Config, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadReader(f)
|
||||
}
|
||||
|
||||
func LoadReader(r io.Reader) (c *Config, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(runtime.Error); ok {
|
||||
panic(r)
|
||||
}
|
||||
err = errors.New(r.(string))
|
||||
}
|
||||
}()
|
||||
|
||||
c = parseSSH(lexSSH(r))
|
||||
return c, err
|
||||
}
|
||||
|
||||
// Config represents an SSH config file.
|
||||
type Config struct {
|
||||
position Position
|
||||
Hosts []*Host
|
||||
}
|
||||
|
||||
func (c *Config) String() string {
|
||||
var buf bytes.Buffer
|
||||
for i := range c.Hosts {
|
||||
buf.WriteString(c.Hosts[i].String())
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type Host struct {
|
||||
// A list of host patterns that should match this host.
|
||||
Patterns []string
|
||||
// A Node is either a key/value pair or a comment line.
|
||||
Nodes []Node
|
||||
// EOLComment is the comment (if any) terminating the Host line.
|
||||
EOLComment string
|
||||
leadingSpace uint16 // TODO: handle spaces vs tabs here.
|
||||
// The file starts with an implicit "Host *" declaration.
|
||||
implicit bool
|
||||
}
|
||||
|
||||
func (h *Host) String() string {
|
||||
var buf bytes.Buffer
|
||||
if h.implicit == false {
|
||||
buf.WriteString(strings.Repeat(" ", int(h.leadingSpace)))
|
||||
buf.WriteString("Host ")
|
||||
buf.WriteString(strings.Join(h.Patterns, " "))
|
||||
if h.EOLComment != "" {
|
||||
buf.WriteString(" #")
|
||||
buf.WriteString(h.EOLComment)
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
for i := range h.Nodes {
|
||||
//fmt.Printf("%q\n", h.Nodes[i].String())
|
||||
buf.WriteString(h.Nodes[i].String())
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type Node interface {
|
||||
Pos() Position
|
||||
String() string
|
||||
}
|
||||
|
||||
type KV struct {
|
||||
Key string
|
||||
Value string
|
||||
Comment string
|
||||
leadingSpace uint16 // Space before the key. TODO handle spaces vs tabs.
|
||||
position Position
|
||||
}
|
||||
|
||||
func (k *KV) Pos() Position {
|
||||
return k.position
|
||||
}
|
||||
|
||||
func (k *KV) String() string {
|
||||
if k == nil {
|
||||
return ""
|
||||
}
|
||||
line := fmt.Sprintf("%s%s %s", strings.Repeat(" ", int(k.leadingSpace)), k.Key, k.Value)
|
||||
if k.Comment != "" {
|
||||
line += " #" + k.Comment
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
type Empty struct {
|
||||
Comment string
|
||||
leadingSpace uint16 // TODO handle spaces vs tabs.
|
||||
position Position
|
||||
}
|
||||
|
||||
func (e *Empty) Pos() Position {
|
||||
return e.position
|
||||
}
|
||||
|
||||
func (e *Empty) String() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Comment == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s#%s", strings.Repeat(" ", int(e.leadingSpace)), e.Comment)
|
||||
}
|
||||
|
||||
func newConfig() *Config {
|
||||
return &Config{
|
||||
Hosts: []*Host{
|
||||
&Host{implicit: true, Patterns: []string{"*"}, Nodes: make([]Node, 0)},
|
||||
},
|
||||
}
|
||||
}
|
||||
31
config_test.go
Normal file
31
config_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ssh_config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadFile(t *testing.T, filename string) []byte {
|
||||
data, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var files = []string{"testdata/config1", "testdata/config2"}
|
||||
|
||||
func TestLoadReader(t *testing.T) {
|
||||
for _, filename := range files {
|
||||
data := loadFile(t, filename)
|
||||
cfg, err := LoadReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := cfg.String()
|
||||
if out != string(data) {
|
||||
t.Errorf("out != data: out: %q\ndata: %q", out, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
207
lexer.go
Normal file
207
lexer.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package ssh_config
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
buffruneio "github.com/pelletier/go-buffruneio"
|
||||
)
|
||||
|
||||
// Define state functions
|
||||
type sshLexStateFn func() sshLexStateFn
|
||||
|
||||
type sshLexer struct {
|
||||
input *buffruneio.Reader // Textual source
|
||||
buffer []rune // Runes composing the current token
|
||||
tokens chan token
|
||||
depth int
|
||||
line uint32
|
||||
col uint16
|
||||
endbufferLine uint32
|
||||
endbufferCol uint16
|
||||
}
|
||||
|
||||
func (s *sshLexer) lexComment(previousState sshLexStateFn) sshLexStateFn {
|
||||
return func() sshLexStateFn {
|
||||
growingString := ""
|
||||
for next := s.peek(); next != '\n' && next != eof; next = s.peek() {
|
||||
if next == '\r' && s.follow("\r\n") {
|
||||
break
|
||||
}
|
||||
growingString += string(next)
|
||||
s.next()
|
||||
}
|
||||
s.emitWithValue(tokenComment, growingString)
|
||||
s.skip()
|
||||
return previousState
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sshLexer) lexKey() sshLexStateFn {
|
||||
growingString := ""
|
||||
|
||||
for r := s.peek(); isKeyChar(r); r = s.peek() {
|
||||
// simplified a lot here
|
||||
if isSpace(r) {
|
||||
s.emitWithValue(tokenKey, growingString)
|
||||
s.skip()
|
||||
return s.lexRvalue
|
||||
}
|
||||
growingString += string(r)
|
||||
s.next()
|
||||
}
|
||||
s.emitWithValue(tokenKey, growingString)
|
||||
return s.lexVoid
|
||||
}
|
||||
|
||||
func (s *sshLexer) lexRvalue() sshLexStateFn {
|
||||
growingString := ""
|
||||
for {
|
||||
next := s.peek()
|
||||
switch next {
|
||||
case '\n':
|
||||
s.emitWithValue(tokenString, growingString)
|
||||
s.skip()
|
||||
return s.lexVoid
|
||||
case '#':
|
||||
s.emitWithValue(tokenString, growingString)
|
||||
s.skip()
|
||||
return s.lexComment(s.lexVoid)
|
||||
case eof:
|
||||
s.next()
|
||||
}
|
||||
if next == eof {
|
||||
break
|
||||
}
|
||||
growingString += string(next)
|
||||
s.next()
|
||||
}
|
||||
s.emit(tokenEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshLexer) read() rune {
|
||||
r, _, err := s.input.ReadRune()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if r == '\n' {
|
||||
s.endbufferLine++
|
||||
s.endbufferCol = 1
|
||||
} else {
|
||||
s.endbufferCol++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *sshLexer) next() rune {
|
||||
r := s.read()
|
||||
|
||||
if r != eof {
|
||||
s.buffer = append(s.buffer, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *sshLexer) lexVoid() sshLexStateFn {
|
||||
for {
|
||||
next := s.peek()
|
||||
switch next {
|
||||
case '#':
|
||||
s.skip()
|
||||
return s.lexComment(s.lexVoid)
|
||||
case '\r':
|
||||
fallthrough
|
||||
case '\n':
|
||||
s.emit(tokenEmptyLine)
|
||||
s.skip()
|
||||
continue
|
||||
}
|
||||
|
||||
if isSpace(next) {
|
||||
s.skip()
|
||||
}
|
||||
|
||||
if isKeyStartChar(next) {
|
||||
return s.lexKey
|
||||
}
|
||||
|
||||
// removed IsKeyStartChar and lexKey. probably will need to readd
|
||||
|
||||
if next == eof {
|
||||
s.next()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
s.emit(tokenEOF)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshLexer) ignore() {
|
||||
s.buffer = make([]rune, 0)
|
||||
s.line = s.endbufferLine
|
||||
s.col = s.endbufferCol
|
||||
}
|
||||
|
||||
func (s *sshLexer) skip() {
|
||||
s.next()
|
||||
s.ignore()
|
||||
}
|
||||
|
||||
func (s *sshLexer) emit(t tokenType) {
|
||||
s.emitWithValue(t, string(s.buffer))
|
||||
}
|
||||
|
||||
func (s *sshLexer) emitWithValue(t tokenType, value string) {
|
||||
tok := token{
|
||||
Position: Position{s.line, s.col},
|
||||
typ: t,
|
||||
val: value,
|
||||
}
|
||||
s.tokens <- tok
|
||||
s.ignore()
|
||||
}
|
||||
|
||||
func (s *sshLexer) peek() rune {
|
||||
r, _, err := s.input.ReadRune()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.input.UnreadRune()
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *sshLexer) follow(next string) bool {
|
||||
for _, expectedRune := range next {
|
||||
r, _, err := s.input.ReadRune()
|
||||
defer s.input.UnreadRune()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if expectedRune != r {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *sshLexer) run() {
|
||||
for state := s.lexVoid; state != nil; {
|
||||
state = state()
|
||||
}
|
||||
close(s.tokens)
|
||||
}
|
||||
|
||||
func lexSSH(input io.Reader) chan token {
|
||||
bufferedInput := buffruneio.NewReader(input)
|
||||
l := &sshLexer{
|
||||
input: bufferedInput,
|
||||
tokens: make(chan token),
|
||||
line: 1,
|
||||
col: 1,
|
||||
endbufferLine: 1,
|
||||
endbufferCol: 1,
|
||||
}
|
||||
go l.run()
|
||||
return l.tokens
|
||||
}
|
||||
147
parser.go
Normal file
147
parser.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ssh_config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type sshParser struct {
|
||||
flow chan token
|
||||
config *Config
|
||||
tokensBuffer []token
|
||||
currentTable []string
|
||||
seenTableKeys []string
|
||||
}
|
||||
|
||||
type sshParserStateFn func() sshParserStateFn
|
||||
|
||||
// Formats and panics an error message based on a token
|
||||
func (p *sshParser) raiseError(tok *token, msg string, args ...interface{}) {
|
||||
panic(tok.Position.String() + ": " + fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (p *sshParser) run() {
|
||||
for state := p.parseStart; state != nil; {
|
||||
state = state()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *sshParser) peek() *token {
|
||||
if len(p.tokensBuffer) != 0 {
|
||||
return &(p.tokensBuffer[0])
|
||||
}
|
||||
|
||||
tok, ok := <-p.flow
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
p.tokensBuffer = append(p.tokensBuffer, tok)
|
||||
return &tok
|
||||
}
|
||||
|
||||
func (p *sshParser) getToken() *token {
|
||||
if len(p.tokensBuffer) != 0 {
|
||||
tok := p.tokensBuffer[0]
|
||||
p.tokensBuffer = p.tokensBuffer[1:]
|
||||
return &tok
|
||||
}
|
||||
tok, ok := <-p.flow
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &tok
|
||||
}
|
||||
|
||||
func (p *sshParser) parseStart() sshParserStateFn {
|
||||
tok := p.peek()
|
||||
|
||||
// end of stream, parsing is finished
|
||||
if tok == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch tok.typ {
|
||||
case tokenComment, tokenEmptyLine:
|
||||
return p.parseComment
|
||||
case tokenKey:
|
||||
return p.parseKV
|
||||
case tokenEOF:
|
||||
return nil
|
||||
default:
|
||||
p.raiseError(tok, fmt.Sprintf("unexpected token %q\n", tok))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *sshParser) parseKV() sshParserStateFn {
|
||||
key := p.getToken()
|
||||
p.assume(tokenString)
|
||||
val := p.getToken()
|
||||
comment := ""
|
||||
tok := p.peek()
|
||||
if tok.typ == tokenComment && tok.Position.Line == val.Position.Line {
|
||||
tok = p.getToken()
|
||||
comment = tok.val
|
||||
}
|
||||
if key.val == "Host" {
|
||||
patterns := strings.Split(val.val, " ")
|
||||
for i := range patterns {
|
||||
if patterns[i] == "" {
|
||||
patterns = append(patterns[:i], patterns[i+1:]...)
|
||||
}
|
||||
}
|
||||
p.config.Hosts = append(p.config.Hosts, &Host{
|
||||
Patterns: patterns,
|
||||
Nodes: make([]Node, 0),
|
||||
EOLComment: comment,
|
||||
})
|
||||
return p.parseStart
|
||||
}
|
||||
lastHost := p.config.Hosts[len(p.config.Hosts)-1]
|
||||
kv := &KV{
|
||||
Key: key.val,
|
||||
Value: val.val,
|
||||
Comment: comment,
|
||||
leadingSpace: uint16(key.Position.Col) - 1,
|
||||
position: key.Position,
|
||||
}
|
||||
lastHost.Nodes = append(lastHost.Nodes, kv)
|
||||
return p.parseStart
|
||||
}
|
||||
|
||||
func (p *sshParser) parseComment() sshParserStateFn {
|
||||
comment := p.getToken()
|
||||
lastHost := p.config.Hosts[len(p.config.Hosts)-1]
|
||||
lastHost.Nodes = append(lastHost.Nodes, &Empty{
|
||||
Comment: comment.val,
|
||||
// account for the "#" as well
|
||||
leadingSpace: comment.Position.Col - 2,
|
||||
position: comment.Position,
|
||||
})
|
||||
return p.parseStart
|
||||
}
|
||||
|
||||
// assume peeks at the next token and ensures it's the right type
|
||||
func (p *sshParser) assume(typ tokenType) {
|
||||
tok := p.peek()
|
||||
if tok == nil {
|
||||
p.raiseError(tok, "was expecting token %s, but token stream is empty", tok)
|
||||
}
|
||||
if tok.typ != typ {
|
||||
p.raiseError(tok, "was expecting token %s, but got %s instead", typ, tok)
|
||||
}
|
||||
}
|
||||
|
||||
func parseSSH(flow chan token) *Config {
|
||||
result := newConfig()
|
||||
result.position = Position{1, 1}
|
||||
parser := &sshParser{
|
||||
flow: flow,
|
||||
config: result,
|
||||
tokensBuffer: make([]token, 0),
|
||||
currentTable: make([]string, 0),
|
||||
seenTableKeys: make([]string, 0),
|
||||
}
|
||||
parser.run()
|
||||
return result
|
||||
}
|
||||
25
position.go
Normal file
25
position.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package ssh_config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Position of a document element within a SSH document.
|
||||
//
|
||||
// Line and Col are both 1-indexed positions for the element's line number and
|
||||
// column number, respectively. Values of zero or less will cause Invalid(),
|
||||
// to return true.
|
||||
type Position struct {
|
||||
Line uint32 // line within the document
|
||||
Col uint16 // column within the line
|
||||
}
|
||||
|
||||
// String representation of the position.
|
||||
// Displays 1-indexed line and column numbers.
|
||||
func (p Position) String() string {
|
||||
return fmt.Sprintf("(%d, %d)", p.Line, p.Col)
|
||||
}
|
||||
|
||||
// Invalid returns whether or not the position is valid (i.e. with negative or
|
||||
// null values)
|
||||
func (p Position) Invalid() bool {
|
||||
return p.Line <= 0 || p.Col <= 0
|
||||
}
|
||||
39
testdata/config1
vendored
Normal file
39
testdata/config1
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
Host localhost 127.0.0.1 # A comment at the end of a host line.
|
||||
NoHostAuthenticationForLocalhost yes
|
||||
|
||||
# A comment
|
||||
# A comment with leading spaces.
|
||||
|
||||
Host wap
|
||||
User root
|
||||
KexAlgorithms diffie-hellman-group1-sha1
|
||||
|
||||
Host [some stuff behind a NAT]
|
||||
Compression yes
|
||||
ProxyCommand ssh -qW %h:%p [NATrouter]
|
||||
|
||||
Host wopr # there are 2 proxies available for this one...
|
||||
User root
|
||||
ProxyCommand sh -c "ssh proxy1 -qW %h:22 || ssh proxy2 -qW %h:22"
|
||||
|
||||
Host dhcp-??
|
||||
UserKnownHostsFile /dev/null
|
||||
StrictHostKeyChecking no
|
||||
User root
|
||||
|
||||
Host [my boxes] [*.mydomain]
|
||||
ForwardAgent yes
|
||||
ForwardX11 yes
|
||||
ForwardX11Trusted yes
|
||||
|
||||
Host *
|
||||
#ControlMaster auto
|
||||
#ControlPath /tmp/ssh-master-%C
|
||||
#ControlPath /tmp/ssh-%u-%r@%h:%p
|
||||
#ControlPersist yes
|
||||
ForwardX11Timeout 52w
|
||||
XAuthLocation /usr/bin/xauth
|
||||
SendEnv LANG LC_*
|
||||
HostKeyAlgorithms ssh-ed25519,ssh-rsa
|
||||
AddressFamily inet
|
||||
#UpdateHostKeys ask
|
||||
50
testdata/config2
vendored
Normal file
50
testdata/config2
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# $OpenBSD: ssh_config,v 1.30 2016/02/20 23:06:23 sobrado Exp $
|
||||
|
||||
# This is the ssh client system-wide configuration file. See
|
||||
# ssh_config(5) for more information. This file provides defaults for
|
||||
# users, and the values can be changed in per-user configuration files
|
||||
# or on the command line.
|
||||
|
||||
# Configuration data is parsed as follows:
|
||||
# 1. command line options
|
||||
# 2. user-specific file
|
||||
# 3. system-wide file
|
||||
# Any configuration value is only changed the first time it is set.
|
||||
# Thus, host-specific definitions should be at the beginning of the
|
||||
# configuration file, and defaults at the end.
|
||||
|
||||
# Site-wide defaults for some commonly used options. For a comprehensive
|
||||
# list of available options, their meanings and defaults, please see the
|
||||
# ssh_config(5) man page.
|
||||
|
||||
# Host *
|
||||
# ForwardAgent no
|
||||
# ForwardX11 no
|
||||
# RhostsRSAAuthentication no
|
||||
# RSAAuthentication yes
|
||||
# PasswordAuthentication yes
|
||||
# HostbasedAuthentication no
|
||||
# GSSAPIAuthentication no
|
||||
# GSSAPIDelegateCredentials no
|
||||
# BatchMode no
|
||||
# CheckHostIP yes
|
||||
# AddressFamily any
|
||||
# ConnectTimeout 0
|
||||
# StrictHostKeyChecking ask
|
||||
# IdentityFile ~/.ssh/identity
|
||||
# IdentityFile ~/.ssh/id_rsa
|
||||
# IdentityFile ~/.ssh/id_dsa
|
||||
# IdentityFile ~/.ssh/id_ecdsa
|
||||
# IdentityFile ~/.ssh/id_ed25519
|
||||
# Port 22
|
||||
# Protocol 2
|
||||
# Cipher 3des
|
||||
# Ciphers aes128-ctr,aes192-ctr,aes256-ctr,arcfour256,arcfour128,aes128-cbc,3des-cbc
|
||||
# MACs hmac-md5,hmac-sha1,umac-64@openssh.com,hmac-ripemd160
|
||||
# EscapeChar ~
|
||||
# Tunnel no
|
||||
# TunnelDevice any:any
|
||||
# PermitLocalCommand no
|
||||
# VisualHostKey no
|
||||
# ProxyCommand ssh -q -W %h:%p gateway.example.com
|
||||
# RekeyLimit 1G 1h
|
||||
48
token.go
Normal file
48
token.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package ssh_config
|
||||
|
||||
import "fmt"
|
||||
|
||||
type token struct {
|
||||
Position
|
||||
typ tokenType
|
||||
val string
|
||||
}
|
||||
|
||||
func (t token) String() string {
|
||||
switch t.typ {
|
||||
case tokenEOF:
|
||||
return "EOF"
|
||||
}
|
||||
return fmt.Sprintf("%q", t.val)
|
||||
}
|
||||
|
||||
type tokenType int
|
||||
|
||||
const (
|
||||
eof = -(iota + 1)
|
||||
)
|
||||
|
||||
const (
|
||||
tokenError tokenType = iota
|
||||
tokenEOF
|
||||
tokenEmptyLine
|
||||
tokenComment
|
||||
tokenKey
|
||||
tokenString
|
||||
)
|
||||
|
||||
func isSpace(r rune) bool {
|
||||
return r == ' ' || r == '\t'
|
||||
}
|
||||
|
||||
func isKeyStartChar(r rune) bool {
|
||||
return !(isSpace(r) || r == '\r' || r == '\n' || r == eof)
|
||||
}
|
||||
|
||||
// I'm not sure that this is correct
|
||||
func isKeyChar(r rune) bool {
|
||||
// Keys start with the first character that isn't whitespace or [ and end
|
||||
// with the last non-whitespace character before the equals sign. Keys
|
||||
// cannot contain a # character."
|
||||
return !(r == '\r' || r == '\n' || r == eof || r == '=')
|
||||
}
|
||||
Reference in New Issue
Block a user