refactor(shellquote): replace go-shellquote with custom shell quoting implementation

This commit is contained in:
Deluan 2026-03-14 09:59:52 -04:00
parent 8939f31d55
commit ba3974ee59
4 changed files with 317 additions and 2 deletions

View file

@ -10,9 +10,9 @@ import (
"strings"
"sync"
"github.com/kballard/go-shellquote"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/shellquote"
)
func start(ctx context.Context, args []string) (Executor, error) {

View file

@ -188,7 +188,7 @@ var _ = Describe("MPV", func() {
It("returns empty slice for empty template", func() {
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
Expect(args).To(Equal([]string{}))
Expect(args).To(BeEmpty())
})
})
})

View file

@ -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
}

View file

@ -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))
})
})