mirror of
https://github.com/containers/podman.git
synced 2025-11-30 10:07:33 +08:00
Update module github.com/openshift/imagebuilder to v1.2.15
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This commit is contained in:
7
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/equal_env_unix.go
generated
vendored
7
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/equal_env_unix.go
generated
vendored
@@ -9,3 +9,10 @@ package shell
|
||||
func EqualEnvKeys(from, to string) bool {
|
||||
return from == to
|
||||
}
|
||||
|
||||
// NormalizeEnvKey returns the key in a normalized form that can be used
|
||||
// for comparison. On Unix this is a no-op. On Windows this converts the
|
||||
// key to uppercase.
|
||||
func NormalizeEnvKey(key string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
7
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/equal_env_windows.go
generated
vendored
7
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/equal_env_windows.go
generated
vendored
@@ -8,3 +8,10 @@ import "strings"
|
||||
func EqualEnvKeys(from, to string) bool {
|
||||
return strings.EqualFold(from, to)
|
||||
}
|
||||
|
||||
// NormalizeEnvKey returns the key in a normalized form that can be used
|
||||
// for comparison. On Unix this is a no-op. On Windows this converts the
|
||||
// key to uppercase.
|
||||
func NormalizeEnvKey(key string) string {
|
||||
return strings.ToUpper(key)
|
||||
}
|
||||
|
||||
358
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/lex.go
generated
vendored
358
vendor/github.com/moby/buildkit/frontend/dockerfile/shell/lex.go
generated
vendored
@@ -3,6 +3,8 @@ package shell
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/scanner"
|
||||
"unicode"
|
||||
@@ -10,6 +12,11 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type EnvGetter interface {
|
||||
Get(string) (string, bool)
|
||||
Keys() []string
|
||||
}
|
||||
|
||||
// Lex performs shell word splitting and variable expansion.
|
||||
//
|
||||
// Lex takes a string and an array of env variables and
|
||||
@@ -17,12 +24,15 @@ import (
|
||||
// tokens. Tries to mimic bash shell process.
|
||||
// It doesn't support all flavors of ${xx:...} formats but new ones can
|
||||
// be added by adding code to the "special ${} format processing" section
|
||||
//
|
||||
// It is not safe to call methods on a Lex instance concurrently.
|
||||
type Lex struct {
|
||||
escapeToken rune
|
||||
RawQuotes bool
|
||||
RawEscapes bool
|
||||
SkipProcessQuotes bool
|
||||
SkipUnsetEnv bool
|
||||
shellWord shellWord
|
||||
}
|
||||
|
||||
// NewLex creates a new Lex which uses escapeToken to escape quotes.
|
||||
@@ -31,10 +41,13 @@ func NewLex(escapeToken rune) *Lex {
|
||||
}
|
||||
|
||||
// ProcessWord will use the 'env' list of environment variables,
|
||||
// and replace any env var references in 'word'.
|
||||
func (s *Lex) ProcessWord(word string, env []string) (string, error) {
|
||||
word, _, err := s.process(word, BuildEnvs(env))
|
||||
return word, err
|
||||
// and replace any env var references in 'word'. It will also
|
||||
// return variables in word which were not found in the 'env' list,
|
||||
// which is useful in later linting.
|
||||
// TODO: rename
|
||||
func (s *Lex) ProcessWord(word string, env EnvGetter) (string, map[string]struct{}, error) {
|
||||
result, err := s.process(word, env, true)
|
||||
return result.Result, result.Unmatched, err
|
||||
}
|
||||
|
||||
// ProcessWords will use the 'env' list of environment variables,
|
||||
@@ -44,63 +57,62 @@ func (s *Lex) ProcessWord(word string, env []string) (string, error) {
|
||||
// this splitting is done **after** the env var substitutions are done.
|
||||
// Note, each one is trimmed to remove leading and trailing spaces (unless
|
||||
// they are quoted", but ProcessWord retains spaces between words.
|
||||
func (s *Lex) ProcessWords(word string, env []string) ([]string, error) {
|
||||
_, words, err := s.process(word, BuildEnvs(env))
|
||||
return words, err
|
||||
func (s *Lex) ProcessWords(word string, env EnvGetter) ([]string, error) {
|
||||
result, err := s.process(word, env, false)
|
||||
return result.Words, err
|
||||
}
|
||||
|
||||
// ProcessWordWithMap will use the 'env' list of environment variables,
|
||||
// and replace any env var references in 'word'.
|
||||
func (s *Lex) ProcessWordWithMap(word string, env map[string]string) (string, error) {
|
||||
word, _, err := s.process(word, env)
|
||||
return word, err
|
||||
type ProcessWordResult struct {
|
||||
Result string
|
||||
Words []string
|
||||
Matched map[string]struct{}
|
||||
Unmatched map[string]struct{}
|
||||
}
|
||||
|
||||
// ProcessWordWithMatches will use the 'env' list of environment variables,
|
||||
// replace any env var references in 'word' and return the env that were used.
|
||||
func (s *Lex) ProcessWordWithMatches(word string, env map[string]string) (string, map[string]struct{}, error) {
|
||||
sw := s.init(word, env)
|
||||
word, _, err := sw.process(word)
|
||||
return word, sw.matches, err
|
||||
func (s *Lex) ProcessWordWithMatches(word string, env EnvGetter) (ProcessWordResult, error) {
|
||||
return s.process(word, env, true)
|
||||
}
|
||||
|
||||
func (s *Lex) ProcessWordsWithMap(word string, env map[string]string) ([]string, error) {
|
||||
_, words, err := s.process(word, env)
|
||||
return words, err
|
||||
}
|
||||
|
||||
func (s *Lex) init(word string, env map[string]string) *shellWord {
|
||||
sw := &shellWord{
|
||||
envs: env,
|
||||
escapeToken: s.escapeToken,
|
||||
skipUnsetEnv: s.SkipUnsetEnv,
|
||||
skipProcessQuotes: s.SkipProcessQuotes,
|
||||
rawQuotes: s.RawQuotes,
|
||||
rawEscapes: s.RawEscapes,
|
||||
matches: make(map[string]struct{}),
|
||||
func (s *Lex) initWord(word string, env EnvGetter, capture bool) *shellWord {
|
||||
sw := &s.shellWord
|
||||
sw.Lex = s
|
||||
sw.envs = env
|
||||
sw.capture = capture
|
||||
sw.rawEscapes = s.RawEscapes
|
||||
if capture {
|
||||
sw.matches = nil
|
||||
sw.nonmatches = nil
|
||||
}
|
||||
sw.scanner.Init(strings.NewReader(word))
|
||||
return sw
|
||||
}
|
||||
|
||||
func (s *Lex) process(word string, env map[string]string) (string, []string, error) {
|
||||
sw := s.init(word, env)
|
||||
return sw.process(word)
|
||||
func (s *Lex) process(word string, env EnvGetter, capture bool) (ProcessWordResult, error) {
|
||||
sw := s.initWord(word, env, capture)
|
||||
word, words, err := sw.process(word)
|
||||
return ProcessWordResult{
|
||||
Result: word,
|
||||
Words: words,
|
||||
Matched: sw.matches,
|
||||
Unmatched: sw.nonmatches,
|
||||
}, err
|
||||
}
|
||||
|
||||
type shellWord struct {
|
||||
scanner scanner.Scanner
|
||||
envs map[string]string
|
||||
escapeToken rune
|
||||
rawQuotes bool
|
||||
rawEscapes bool
|
||||
skipUnsetEnv bool
|
||||
skipProcessQuotes bool
|
||||
matches map[string]struct{}
|
||||
*Lex
|
||||
wordsBuffer strings.Builder
|
||||
scanner scanner.Scanner
|
||||
envs EnvGetter
|
||||
rawEscapes bool
|
||||
capture bool // capture matches and nonmatches
|
||||
matches map[string]struct{}
|
||||
nonmatches map[string]struct{}
|
||||
}
|
||||
|
||||
func (sw *shellWord) process(source string) (string, []string, error) {
|
||||
word, words, err := sw.processStopOn(scanner.EOF)
|
||||
word, words, err := sw.processStopOn(scanner.EOF, sw.rawEscapes)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "failed to process %q", source)
|
||||
}
|
||||
@@ -108,16 +120,16 @@ func (sw *shellWord) process(source string) (string, []string, error) {
|
||||
}
|
||||
|
||||
type wordsStruct struct {
|
||||
word string
|
||||
buf *strings.Builder
|
||||
words []string
|
||||
inWord bool
|
||||
}
|
||||
|
||||
func (w *wordsStruct) addChar(ch rune) {
|
||||
if unicode.IsSpace(ch) && w.inWord {
|
||||
if len(w.word) != 0 {
|
||||
w.words = append(w.words, w.word)
|
||||
w.word = ""
|
||||
if w.buf.Len() != 0 {
|
||||
w.words = append(w.words, w.buf.String())
|
||||
w.buf.Reset()
|
||||
w.inWord = false
|
||||
}
|
||||
} else if !unicode.IsSpace(ch) {
|
||||
@@ -126,7 +138,7 @@ func (w *wordsStruct) addChar(ch rune) {
|
||||
}
|
||||
|
||||
func (w *wordsStruct) addRawChar(ch rune) {
|
||||
w.word += string(ch)
|
||||
w.buf.WriteRune(ch)
|
||||
w.inWord = true
|
||||
}
|
||||
|
||||
@@ -137,16 +149,16 @@ func (w *wordsStruct) addString(str string) {
|
||||
}
|
||||
|
||||
func (w *wordsStruct) addRawString(str string) {
|
||||
w.word += str
|
||||
w.buf.WriteString(str)
|
||||
w.inWord = true
|
||||
}
|
||||
|
||||
func (w *wordsStruct) getWords() []string {
|
||||
if len(w.word) > 0 {
|
||||
w.words = append(w.words, w.word)
|
||||
if w.buf.Len() > 0 {
|
||||
w.words = append(w.words, w.buf.String())
|
||||
|
||||
// Just in case we're called again by mistake
|
||||
w.word = ""
|
||||
w.buf.Reset()
|
||||
w.inWord = false
|
||||
}
|
||||
return w.words
|
||||
@@ -154,18 +166,31 @@ func (w *wordsStruct) getWords() []string {
|
||||
|
||||
// Process the word, starting at 'pos', and stop when we get to the
|
||||
// end of the word or the 'stopChar' character
|
||||
func (sw *shellWord) processStopOn(stopChar rune) (string, []string, error) {
|
||||
var result bytes.Buffer
|
||||
func (sw *shellWord) processStopOn(stopChar rune, rawEscapes bool) (string, []string, error) {
|
||||
// result buffer can't be currently shared for shellWord as it is called internally
|
||||
// by processDollar
|
||||
var result strings.Builder
|
||||
sw.wordsBuffer.Reset()
|
||||
var words wordsStruct
|
||||
words.buf = &sw.wordsBuffer
|
||||
|
||||
// no need to initialize all the time
|
||||
var charFuncMapping = map[rune]func() (string, error){
|
||||
'$': sw.processDollar,
|
||||
}
|
||||
if !sw.skipProcessQuotes {
|
||||
if !sw.SkipProcessQuotes {
|
||||
charFuncMapping['\''] = sw.processSingleQuote
|
||||
charFuncMapping['"'] = sw.processDoubleQuote
|
||||
}
|
||||
|
||||
// temporarily set sw.rawEscapes if needed
|
||||
if rawEscapes != sw.rawEscapes {
|
||||
sw.rawEscapes = rawEscapes
|
||||
defer func() {
|
||||
sw.rawEscapes = !rawEscapes
|
||||
}()
|
||||
}
|
||||
|
||||
for sw.scanner.Peek() != scanner.EOF {
|
||||
ch := sw.scanner.Peek()
|
||||
|
||||
@@ -230,7 +255,7 @@ func (sw *shellWord) processSingleQuote() (string, error) {
|
||||
var result bytes.Buffer
|
||||
|
||||
ch := sw.scanner.Next()
|
||||
if sw.rawQuotes {
|
||||
if sw.RawQuotes {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
|
||||
@@ -240,7 +265,7 @@ func (sw *shellWord) processSingleQuote() (string, error) {
|
||||
case scanner.EOF:
|
||||
return "", errors.New("unexpected end of statement while looking for matching single-quote")
|
||||
case '\'':
|
||||
if sw.rawQuotes {
|
||||
if sw.RawQuotes {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
return result.String(), nil
|
||||
@@ -265,7 +290,7 @@ func (sw *shellWord) processDoubleQuote() (string, error) {
|
||||
var result bytes.Buffer
|
||||
|
||||
ch := sw.scanner.Next()
|
||||
if sw.rawQuotes {
|
||||
if sw.RawQuotes {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
|
||||
@@ -275,7 +300,7 @@ func (sw *shellWord) processDoubleQuote() (string, error) {
|
||||
return "", errors.New("unexpected end of statement while looking for matching double-quote")
|
||||
case '"':
|
||||
ch := sw.scanner.Next()
|
||||
if sw.rawQuotes {
|
||||
if sw.RawQuotes {
|
||||
result.WriteRune(ch)
|
||||
}
|
||||
return result.String(), nil
|
||||
@@ -319,7 +344,7 @@ func (sw *shellWord) processDollar() (string, error) {
|
||||
return "$", nil
|
||||
}
|
||||
value, found := sw.getEnv(name)
|
||||
if !found && sw.skipUnsetEnv {
|
||||
if !found && sw.SkipUnsetEnv {
|
||||
return "$" + name, nil
|
||||
}
|
||||
return value, nil
|
||||
@@ -342,7 +367,7 @@ func (sw *shellWord) processDollar() (string, error) {
|
||||
case '}':
|
||||
// Normal ${xx} case
|
||||
value, set := sw.getEnv(name)
|
||||
if !set && sw.skipUnsetEnv {
|
||||
if !set && sw.SkipUnsetEnv {
|
||||
return fmt.Sprintf("${%s}", name), nil
|
||||
}
|
||||
return value, nil
|
||||
@@ -351,8 +376,9 @@ func (sw *shellWord) processDollar() (string, error) {
|
||||
ch = sw.scanner.Next()
|
||||
chs += string(ch)
|
||||
fallthrough
|
||||
case '+', '-', '?':
|
||||
word, _, err := sw.processStopOn('}')
|
||||
case '+', '-', '?', '#', '%':
|
||||
rawEscapes := ch == '#' || ch == '%'
|
||||
word, _, err := sw.processStopOn('}', rawEscapes)
|
||||
if err != nil {
|
||||
if sw.scanner.Peek() == scanner.EOF {
|
||||
return "", errors.New("syntax error: missing '}'")
|
||||
@@ -363,7 +389,7 @@ func (sw *shellWord) processDollar() (string, error) {
|
||||
// Grab the current value of the variable in question so we
|
||||
// can use it to determine what to do based on the modifier
|
||||
value, set := sw.getEnv(name)
|
||||
if sw.skipUnsetEnv && !set {
|
||||
if sw.SkipUnsetEnv && !set {
|
||||
return fmt.Sprintf("${%s%s%s}", name, chs, word), nil
|
||||
}
|
||||
|
||||
@@ -394,9 +420,61 @@ func (sw *shellWord) processDollar() (string, error) {
|
||||
return "", errors.Errorf("%s: %s", name, message)
|
||||
}
|
||||
return value, nil
|
||||
case '%', '#':
|
||||
// %/# matches the shortest pattern expansion, %%/## the longest
|
||||
greedy := false
|
||||
|
||||
if len(word) > 0 && word[0] == byte(ch) {
|
||||
greedy = true
|
||||
word = word[1:]
|
||||
}
|
||||
|
||||
if ch == '%' {
|
||||
return trimSuffix(word, value, greedy)
|
||||
}
|
||||
return trimPrefix(word, value, greedy)
|
||||
default:
|
||||
return "", errors.Errorf("unsupported modifier (%s) in substitution", chs)
|
||||
}
|
||||
case '/':
|
||||
replaceAll := sw.scanner.Peek() == '/'
|
||||
if replaceAll {
|
||||
sw.scanner.Next()
|
||||
}
|
||||
|
||||
pattern, _, err := sw.processStopOn('/', true)
|
||||
if err != nil {
|
||||
if sw.scanner.Peek() == scanner.EOF {
|
||||
return "", errors.New("syntax error: missing '/' in ${}")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
replacement, _, err := sw.processStopOn('}', true)
|
||||
if err != nil {
|
||||
if sw.scanner.Peek() == scanner.EOF {
|
||||
return "", errors.New("syntax error: missing '}'")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
value, set := sw.getEnv(name)
|
||||
if sw.SkipUnsetEnv && !set {
|
||||
return fmt.Sprintf("${%s/%s/%s}", name, pattern, replacement), nil
|
||||
}
|
||||
|
||||
re, err := convertShellPatternToRegex(pattern, true, false)
|
||||
if err != nil {
|
||||
return "", errors.Errorf("invalid pattern (%s) in substitution: %s", pattern, err)
|
||||
}
|
||||
if replaceAll {
|
||||
value = re.ReplaceAllString(value, replacement)
|
||||
} else {
|
||||
if idx := re.FindStringIndex(value); idx != nil {
|
||||
value = value[0:idx[0]] + replacement + value[idx[1]:]
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return "", errors.Errorf("unsupported modifier (%s) in substitution", chs)
|
||||
}
|
||||
@@ -444,31 +522,155 @@ func isSpecialParam(char rune) bool {
|
||||
}
|
||||
|
||||
func (sw *shellWord) getEnv(name string) (string, bool) {
|
||||
for key, value := range sw.envs {
|
||||
if EqualEnvKeys(name, key) {
|
||||
v, ok := sw.envs.Get(name)
|
||||
if ok {
|
||||
if sw.capture {
|
||||
if sw.matches == nil {
|
||||
sw.matches = make(map[string]struct{})
|
||||
}
|
||||
sw.matches[name] = struct{}{}
|
||||
return value, true
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
if sw.capture {
|
||||
if sw.nonmatches == nil {
|
||||
sw.nonmatches = make(map[string]struct{})
|
||||
}
|
||||
sw.nonmatches[name] = struct{}{}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func BuildEnvs(env []string) map[string]string {
|
||||
func EnvsFromSlice(env []string) EnvGetter {
|
||||
envs := map[string]string{}
|
||||
|
||||
keys := make([]string, 0, len(env))
|
||||
for _, e := range env {
|
||||
i := strings.Index(e, "=")
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
keys = append(keys, k)
|
||||
envs[NormalizeEnvKey(k)] = v
|
||||
}
|
||||
return &envGetter{env: envs, keys: keys}
|
||||
}
|
||||
|
||||
if i < 0 {
|
||||
envs[e] = ""
|
||||
} else {
|
||||
k := e[:i]
|
||||
v := e[i+1:]
|
||||
type envGetter struct {
|
||||
env map[string]string
|
||||
keys []string
|
||||
}
|
||||
|
||||
// overwrite value if key already exists
|
||||
envs[k] = v
|
||||
}
|
||||
var _ EnvGetter = &envGetter{}
|
||||
|
||||
func (e *envGetter) Get(key string) (string, bool) {
|
||||
key = NormalizeEnvKey(key)
|
||||
v, ok := e.env[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (e *envGetter) Keys() []string {
|
||||
return e.keys
|
||||
}
|
||||
|
||||
// convertShellPatternToRegex converts a shell-like wildcard pattern
|
||||
// (? is a single char, * either the shortest or longest (greedy) string)
|
||||
// to an equivalent regular expression.
|
||||
//
|
||||
// Based on
|
||||
// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_13
|
||||
// but without the bracket expressions (`[]`)
|
||||
func convertShellPatternToRegex(pattern string, greedy bool, anchored bool) (*regexp.Regexp, error) {
|
||||
var s scanner.Scanner
|
||||
s.Init(strings.NewReader(pattern))
|
||||
var out strings.Builder
|
||||
out.Grow(len(pattern) + 4)
|
||||
|
||||
// match only at the beginning of the string
|
||||
if anchored {
|
||||
out.WriteByte('^')
|
||||
}
|
||||
|
||||
return envs
|
||||
// default: non-greedy wildcards
|
||||
starPattern := ".*?"
|
||||
if greedy {
|
||||
starPattern = ".*"
|
||||
}
|
||||
|
||||
for tok := s.Next(); tok != scanner.EOF; tok = s.Next() {
|
||||
switch tok {
|
||||
case '*':
|
||||
out.WriteString(starPattern)
|
||||
continue
|
||||
case '?':
|
||||
out.WriteByte('.')
|
||||
continue
|
||||
case '\\':
|
||||
// } and / as part of ${} need to be escaped, but the escape isn't part
|
||||
// of the pattern
|
||||
if s.Peek() == '}' || s.Peek() == '/' {
|
||||
continue
|
||||
}
|
||||
out.WriteRune('\\')
|
||||
tok = s.Next()
|
||||
if tok != '*' && tok != '?' && tok != '\\' {
|
||||
return nil, errors.Errorf("invalid escape '\\%c'", tok)
|
||||
}
|
||||
// regex characters that need to be escaped
|
||||
// escaping closing is optional, but done for consistency
|
||||
case '[', ']', '{', '}', '.', '+', '(', ')', '|', '^', '$':
|
||||
out.WriteByte('\\')
|
||||
}
|
||||
out.WriteRune(tok)
|
||||
}
|
||||
return regexp.Compile(out.String())
|
||||
}
|
||||
|
||||
func trimPrefix(word, value string, greedy bool) (string, error) {
|
||||
re, err := convertShellPatternToRegex(word, greedy, true)
|
||||
if err != nil {
|
||||
return "", errors.Errorf("invalid pattern (%s) in substitution: %s", word, err)
|
||||
}
|
||||
|
||||
if idx := re.FindStringIndex(value); idx != nil {
|
||||
value = value[idx[1]:]
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// reverse without avoid reversing escapes, i.e. a\*c -> c\*a
|
||||
func reversePattern(pattern string) string {
|
||||
patternRunes := []rune(pattern)
|
||||
out := make([]rune, len(patternRunes))
|
||||
lastIdx := len(patternRunes) - 1
|
||||
for i := 0; i <= lastIdx; {
|
||||
tok := patternRunes[i]
|
||||
outIdx := lastIdx - i
|
||||
if tok == '\\' && i != lastIdx {
|
||||
out[outIdx-1] = tok
|
||||
// the pattern is taken from a ${var#pattern}, so the last
|
||||
// character can't be an escape character
|
||||
out[outIdx] = patternRunes[i+1]
|
||||
i += 2
|
||||
} else {
|
||||
out[outIdx] = tok
|
||||
i++
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func reverseString(str string) string {
|
||||
out := []rune(str)
|
||||
slices.Reverse(out)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func trimSuffix(pattern, word string, greedy bool) (string, error) {
|
||||
// regular expressions can't handle finding the shortest rightmost
|
||||
// string so we reverse both search space and pattern to convert it
|
||||
// to a leftmost search in both cases
|
||||
pattern = reversePattern(pattern)
|
||||
word = reverseString(word)
|
||||
str, err := trimPrefix(pattern, word, greedy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reverseString(str), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user