refactor(shellquote): replace go-shellquote with custom shell quoting implementation
This commit is contained in:
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/kballard/go-shellquote"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/utils/shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
func start(ctx context.Context, args []string) (Executor, error) {
|
func start(ctx context.Context, args []string) (Executor, error) {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ var _ = Describe("MPV", func() {
|
|||||||
|
|
||||||
It("returns empty slice for empty template", func() {
|
It("returns empty slice for empty template", func() {
|
||||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||||
Expect(args).To(Equal([]string{}))
|
Expect(args).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package shellquote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnterminatedSingleQuote = errors.New("unterminated single-quoted string")
|
||||||
|
ErrUnterminatedDoubleQuote = errors.New("unterminated double-quoted string")
|
||||||
|
ErrUnterminatedEscape = errors.New("unterminated backslash-escape")
|
||||||
|
)
|
||||||
|
|
||||||
|
type state int
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateUnquoted state = iota
|
||||||
|
stateSingleQuoted
|
||||||
|
stateDoubleQuoted
|
||||||
|
)
|
||||||
|
|
||||||
|
// Split splits a string into words following POSIX-like shell quoting rules.
|
||||||
|
// It handles single quotes, double quotes, and backslash escapes.
|
||||||
|
func Split(input string) ([]string, error) {
|
||||||
|
var words []string
|
||||||
|
var word strings.Builder
|
||||||
|
inWord := false
|
||||||
|
parseState := stateUnquoted
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(input) {
|
||||||
|
ch := input[i]
|
||||||
|
|
||||||
|
switch parseState {
|
||||||
|
case stateUnquoted:
|
||||||
|
switch {
|
||||||
|
case ch == '\\':
|
||||||
|
if i+1 >= len(input) {
|
||||||
|
return nil, ErrUnterminatedEscape
|
||||||
|
}
|
||||||
|
if input[i+1] == '\n' {
|
||||||
|
// Line continuation: skip both backslash and newline
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
word.WriteByte(input[i])
|
||||||
|
inWord = true
|
||||||
|
case ch == '\'':
|
||||||
|
parseState = stateSingleQuoted
|
||||||
|
inWord = true
|
||||||
|
case ch == '"':
|
||||||
|
parseState = stateDoubleQuoted
|
||||||
|
inWord = true
|
||||||
|
case ch == ' ' || ch == '\t' || ch == '\n':
|
||||||
|
if inWord {
|
||||||
|
words = append(words, word.String())
|
||||||
|
word.Reset()
|
||||||
|
inWord = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
word.WriteByte(ch)
|
||||||
|
inWord = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case stateSingleQuoted:
|
||||||
|
if ch == '\'' {
|
||||||
|
parseState = stateUnquoted
|
||||||
|
} else {
|
||||||
|
word.WriteByte(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
case stateDoubleQuoted:
|
||||||
|
switch {
|
||||||
|
case ch == '"':
|
||||||
|
parseState = stateUnquoted
|
||||||
|
case ch == '\\':
|
||||||
|
if i+1 >= len(input) {
|
||||||
|
return nil, ErrUnterminatedEscape
|
||||||
|
}
|
||||||
|
next := input[i+1]
|
||||||
|
// In double quotes, backslash only escapes: $ ` " \n \
|
||||||
|
if next == '$' || next == '`' || next == '"' || next == '\n' || next == '\\' {
|
||||||
|
if next == '\n' {
|
||||||
|
// Line continuation: skip both backslash and newline
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
word.WriteByte(next)
|
||||||
|
} else {
|
||||||
|
// Backslash is literal for other characters
|
||||||
|
word.WriteByte(ch)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
word.WriteByte(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parseState {
|
||||||
|
case stateSingleQuoted:
|
||||||
|
return nil, ErrUnterminatedSingleQuote
|
||||||
|
case stateDoubleQuoted:
|
||||||
|
return nil, ErrUnterminatedDoubleQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
if inWord {
|
||||||
|
words = append(words, word.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return words, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package shellquote_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/utils/shellquote"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShellquote(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Shellquote Suite")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Split", func() {
|
||||||
|
It("splits simple space-separated words", func() {
|
||||||
|
words, err := shellquote.Split("a b c")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"a", "b", "c"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles multiple spaces between words", func() {
|
||||||
|
words, err := shellquote.Split("a b")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"a", "b"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles single-quoted strings", func() {
|
||||||
|
words, err := shellquote.Split("'hello world'")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"hello world"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles double-quoted strings", func() {
|
||||||
|
words, err := shellquote.Split(`"hello world"`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"hello world"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles backslash escapes in unquoted mode", func() {
|
||||||
|
words, err := shellquote.Split(`hello\ world`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"hello world"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles escaped quotes inside double quotes", func() {
|
||||||
|
words, err := shellquote.Split(`"hello \" world"`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{`hello " world`}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles mixed quoting in a single argument", func() {
|
||||||
|
words, err := shellquote.Split("he'llo wo'rld")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"hello world"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty slice for empty input", func() {
|
||||||
|
words, err := shellquote.Split("")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty slice for whitespace-only input", func() {
|
||||||
|
words, err := shellquote.Split(" ")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated single quote", func() {
|
||||||
|
_, err := shellquote.Split("'hello")
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated double quote", func() {
|
||||||
|
_, err := shellquote.Split(`"hello`)
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated escape", func() {
|
||||||
|
_, err := shellquote.Split(`hello\`)
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles tabs and newlines as delimiters", func() {
|
||||||
|
words, err := shellquote.Split("a\tb\nc")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"a", "b", "c"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("parses the default MPV command template", func() {
|
||||||
|
words, err := shellquote.Split("mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(HaveLen(6))
|
||||||
|
Expect(words).To(Equal([]string{
|
||||||
|
"mpv",
|
||||||
|
"--audio-device=%d",
|
||||||
|
"--no-audio-display",
|
||||||
|
"--pause",
|
||||||
|
"%f",
|
||||||
|
"--input-ipc-server=%s",
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves spaces in quoted paths", func() {
|
||||||
|
words, err := shellquote.Split(`--ao-pcm-file="/audio/my folder/file"`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{`--ao-pcm-file=/audio/my folder/file`}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles backslash in double quotes for special chars", func() {
|
||||||
|
words, err := shellquote.Split(`"hello\\world"`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{`hello\world`}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves backslash in double quotes for non-special chars", func() {
|
||||||
|
words, err := shellquote.Split(`"hello\nworld"`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{`hello\nworld`}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles escaped newline in double quotes", func() {
|
||||||
|
words, err := shellquote.Split("\"hello\\\nworld\"")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"helloworld"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cases from original go-shellquote test suite
|
||||||
|
It("handles shell glob characters as literals", func() {
|
||||||
|
words, err := shellquote.Split("glob* test?")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"glob*", "test?"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles backslash-escaped special characters", func() {
|
||||||
|
words, err := shellquote.Split("don\\'t you know the dewey decimal system\\?")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"don't", "you", "know", "the", "dewey", "decimal", "system?"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles single-quote escape idiom", func() {
|
||||||
|
// Shell idiom: end single-quote, escaped literal quote, start single-quote again
|
||||||
|
words, err := shellquote.Split("'don'\\''t you know the dewey decimal system?'")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"don't you know the dewey decimal system?"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles empty string argument via quotes", func() {
|
||||||
|
words, err := shellquote.Split("one '' two")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"one", "", "two"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles backslash-newline joining words in unquoted mode", func() {
|
||||||
|
words, err := shellquote.Split("text with\\\na backslash-escaped newline")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"text", "witha", "backslash-escaped", "newline"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles quoted newline inside double quotes", func() {
|
||||||
|
words, err := shellquote.Split("text \"with\na\" quoted newline")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"text", "with\na", "quoted", "newline"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles complex double-quoted escapes with backslash-newline", func() {
|
||||||
|
words, err := shellquote.Split("\"quoted\\d\\\\\\\" text with\\\na backslash-escaped newline\"")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"quoted\\d\\\" text witha backslash-escaped newline"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles backslash-newline between words", func() {
|
||||||
|
words, err := shellquote.Split("text with an escaped \\\n newline in the middle")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"text", "with", "an", "escaped", "newline", "in", "the", "middle"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles double-quoted substring concatenation", func() {
|
||||||
|
words, err := shellquote.Split(`foo"bar"baz`)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(words).To(Equal([]string{"foobarbaz"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated quote after escape idiom", func() {
|
||||||
|
_, err := shellquote.Split("'test'\\''ing")
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated double quote with single quote inside", func() {
|
||||||
|
_, err := shellquote.Split("\"foo'bar")
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unterminated escape with leading whitespace", func() {
|
||||||
|
_, err := shellquote.Split(" \\")
|
||||||
|
Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user