package dotenv import ( "bytes" "errors" "fmt" "os" "regexp" "strings" "unicode" ) // // copied from https://github.com/joho/godotenv/blob/main/parser.go // const ( charComment = '#' prefixSingleQuote = '\'' prefixDoubleQuote = '"' exportPrefix = "export" ) func parseBytes(src []byte, out map[string]string) error { src = bytes.Replace(src, []byte("\r\n"), []byte("\n"), -1) cutset := src for { cutset = getStatementStart(cutset) if cutset == nil { // reached end of file break } key, left, err := locateKeyName(cutset) if err != nil { return err } value, left, err := extractVarValue(left, out) if err != nil { return err } out[key] = value cutset = left } return nil } // getStatementPosition returns position of statement begin. // // It skips any comment line or non-whitespace character. func getStatementStart(src []byte) []byte { pos := indexOfNonSpaceChar(src) if pos == -1 { return nil } src = src[pos:] if src[0] != charComment { return src } // skip comment section pos = bytes.IndexFunc(src, isCharFunc('\n')) if pos == -1 { return nil } return getStatementStart(src[pos:]) } // locateKeyName locates and parses key name and returns rest of slice func locateKeyName(src []byte) (key string, cutset []byte, err error) { // trim "export" and space at beginning src = bytes.TrimLeftFunc(src, isSpace) if bytes.HasPrefix(src, []byte(exportPrefix)) { trimmed := bytes.TrimPrefix(src, []byte(exportPrefix)) if bytes.IndexFunc(trimmed, isSpace) == 0 { src = bytes.TrimLeftFunc(trimmed, isSpace) } } // locate key name end and validate it in single loop offset := 0 loop: for i, char := range src { rchar := rune(char) if isSpace(rchar) { continue } switch char { case '=', ':': // library also supports yaml-style value declaration key = string(src[0:i]) offset = i + 1 break loop case '_': default: // variable name should match [A-Za-z0-9_.] if unicode.IsLetter(rchar) || unicode.IsNumber(rchar) || rchar == '.' { continue } return "", nil, fmt.Errorf( `unexpected character %q in variable name near %q`, string(char), string(src)) } } if len(src) == 0 { return "", nil, errors.New("zero length string") } // trim whitespace key = strings.TrimRightFunc(key, unicode.IsSpace) cutset = bytes.TrimLeftFunc(src[offset:], isSpace) return key, cutset, nil } // extractVarValue extracts variable value and returns rest of slice func extractVarValue(src []byte, vars map[string]string) (value string, rest []byte, err error) { quote, hasPrefix := hasQuotePrefix(src) if !hasPrefix { // unquoted value - read until end of line endOfLine := bytes.IndexFunc(src, isLineEnd) // Hit EOF without a trailing newline if endOfLine == -1 { endOfLine = len(src) if endOfLine == 0 { return "", nil, nil } } // Convert line to rune away to do accurate countback of runes line := []rune(string(src[0:endOfLine])) // Assume end of line is end of var endOfVar := len(line) if endOfVar == 0 { return "", src[endOfLine:], nil } // Work backwards to check if the line ends in whitespace then // a comment (ie asdasd # some comment) for i := endOfVar - 1; i >= 0; i-- { if line[i] == charComment && i > 0 { if isSpace(line[i-1]) { endOfVar = i break } } } trimmed := strings.TrimFunc(string(line[0:endOfVar]), isSpace) return expandVariables(trimmed, vars), src[endOfLine:], nil } // lookup quoted string terminator for i := 1; i < len(src); i++ { if char := src[i]; char != quote { continue } // skip escaped quote symbol (\" or \', depends on quote) if prevChar := src[i-1]; prevChar == '\\' { continue } // trim quotes trimFunc := isCharFunc(rune(quote)) value = string(bytes.TrimLeftFunc(bytes.TrimRightFunc(src[0:i], trimFunc), trimFunc)) if quote == prefixDoubleQuote { // unescape newlines for double quote (this is compat feature) // and expand environment variables value = expandVariables(expandEscapes(value), vars) } return value, src[i+1:], nil } // return formatted error if quoted string is not terminated valEndIndex := bytes.IndexFunc(src, isCharFunc('\n')) if valEndIndex == -1 { valEndIndex = len(src) } return "", nil, fmt.Errorf("unterminated quoted value %s", src[:valEndIndex]) } func expandEscapes(str string) string { out := escapeRegex.ReplaceAllStringFunc(str, func(match string) string { c := strings.TrimPrefix(match, `\`) switch c { case "n": return "\n" case "r": return "\r" default: return match } }) return unescapeCharsRegex.ReplaceAllString(out, "$1") } func indexOfNonSpaceChar(src []byte) int { return bytes.IndexFunc(src, func(r rune) bool { return !unicode.IsSpace(r) }) } // hasQuotePrefix reports whether charset starts with single or double quote and returns quote character func hasQuotePrefix(src []byte) (prefix byte, isQuored bool) { if len(src) == 0 { return 0, false } switch prefix := src[0]; prefix { case prefixDoubleQuote, prefixSingleQuote: return prefix, true default: return 0, false } } func isCharFunc(char rune) func(rune) bool { return func(v rune) bool { return v == char } } // isSpace reports whether the rune is a space character but not line break character // // this differs from unicode.IsSpace, which also applies line break as space func isSpace(r rune) bool { switch r { case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0: return true } return false } func isLineEnd(r rune) bool { if r == '\n' || r == '\r' { return true } return false } var ( escapeRegex = regexp.MustCompile(`\\.`) expandVarRegex = regexp.MustCompile(`(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?`) unescapeCharsRegex = regexp.MustCompile(`\\([^$])`) ) func expandVariables(v string, m map[string]string) string { return expandVarRegex.ReplaceAllStringFunc(v, func(s string) string { submatch := expandVarRegex.FindStringSubmatch(s) if submatch == nil { return s } if submatch[1] == "\\" || submatch[2] == "(" { return submatch[0][1:] } else if submatch[4] != "" { if val, ok := m[submatch[4]]; ok { return val } if val, ok := os.LookupEnv(submatch[4]); ok { return val } return m[submatch[4]] } return s }) }