package str_test
import (
"mime"
"regexp"
"strings"
"github.com/navidrome/navidrome/utils/str"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var asciiFilenameRe = regexp.MustCompile(`(?:^|; )filename="([^"]*)"`)
func asciiName(filename string) string {
m := asciiFilenameRe.FindAllStringSubmatch(str.ContentDispositionAttachment(filename), -1)
ExpectWithOffset(1, m).To(HaveLen(1), "expected exactly one quoted filename parameter")
return m[0][1]
}
func decodedName(filename string) string {
disp, params, err := mime.ParseMediaType(str.ContentDispositionAttachment(filename))
ExpectWithOffset(1, err).ToNot(HaveOccurred())
ExpectWithOffset(1, disp).To(Equal("attachment"))
return params["filename"]
}
var _ = Describe("ContentDispositionAttachment", func() {
Describe("parameter injection", func() {
const attack = `party"; filename="evil.html.m3u`
It("does not let a quote in the name open a second parameter", func() {
Expect(str.ContentDispositionAttachment(attack)).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`))
})
It("keeps the header parseable", func() {
Expect(decodedName(attack)).To(Equal("party_; filename=_evil.html.m3u"))
})
It("does not let a quote in a non-ASCII name inject either", func() {
const utf8Attack = `ζ±δΊ¬"; filename="evil.html.m3u`
header := str.ContentDispositionAttachment(utf8Attack)
Expect(strings.Count(header, `"`)).To(Equal(2))
Expect(decodedName(utf8Attack)).To(Equal("ζ±δΊ¬_; filename=_evil.html.m3u"))
})
})
Describe("ASCII names", func() {
It("sends only the quoted filename, unchanged", func() {
Expect(str.ContentDispositionAttachment("My Playlist.m3u")).To(Equal(`attachment; filename="My Playlist.m3u"`))
})
It("keeps leading dots", func() {
Expect(asciiName("...And Justice for All.zip")).To(Equal("...And Justice for All.zip"))
})
It("trims surrounding spaces and trailing dots", func() {
Expect(asciiName(" Greatest Hits .zip")).To(Equal("Greatest Hits.zip"))
Expect(asciiName("Loose End. ")).To(Equal("Loose End"))
})
It("falls back to a placeholder when only dots are left", func() {
Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`))
})
It("caps a long name whose last dot is not an extension", func() {
name := asciiName("a." + strings.Repeat("x", 300))
Expect(len(name)).To(BeNumerically("<=", 255))
Expect(name).To(HavePrefix("a.xxx"))
})
It("replaces path separators and reserved characters", func() {
Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip"))
})
It("drops control characters", func() {
Expect(asciiName("line\r\nbreak\x00\t.mp3")).To(Equal("linebreak.mp3"))
})
})
Describe("non-ASCII names", func() {
It("transliterates accents in the ASCII fallback", func() {
Expect(asciiName("LegiΓ£o Urbana.zip")).To(Equal("Legiao Urbana.zip"))
})
It("converts typographic punctuation in the ASCII fallback", func() {
Expect(asciiName("Sheβs a Woman β Live.mp3")).To(Equal("She's a Woman - Live.mp3"))
Expect(asciiName("βHeroesβ.mp3")).To(Equal("_Heroes_.mp3"))
Expect(decodedName("Sheβs a Woman.mp3")).To(Equal("Sheβs a Woman.mp3"))
})
It("keeps the extension when no ASCII letters survive", func() {
Expect(asciiName("ζ±δΊ¬.mp3")).To(Equal("download.mp3"))
Expect(asciiName("ΠΠΈΠ½ΠΎ.m3u")).To(Equal("download.m3u"))
Expect(asciiName("ζ±δΊ¬")).To(Equal("download"))
})
DescribeTable("filename* carries the sanitized UTF-8 name",
func(filename, expected string) {
Expect(decodedName(filename)).To(Equal(expected))
},
Entry("accented", "LegiΓ£o Urbana.zip", "LegiΓ£o Urbana.zip"),
Entry("CJK", "ζ±δΊ¬.zip", "ζ±δΊ¬.zip"),
Entry("emoji", "π΅ mix.zip", "π΅ mix.zip"),
Entry("comma and percent", "Sigur RΓ³s, 100%.zip", "Sigur RΓ³s, 100%.zip"),
Entry("path separators", "Sigur RΓ³s/Live: Heima.zip", "Sigur RΓ³s_Live_ Heima.zip"),
Entry("control characters", "Sigur RΓ³s\r\n\x00.zip", "Sigur RΓ³s.zip"),
Entry("bidi override", "BjΓΆrk\u202Eexe.mp3", "BjΓΆrkexe.mp3"),
Entry("invalid UTF-8", "Bj\xf6rk CafΓ©.mp3", "Bj_rk CafΓ©.mp3"),
)
It("caps the name length and keeps the extension", func() {
name := decodedName(strings.Repeat("ζ±", 300) + ".zip")
Expect(len(name)).To(BeNumerically("<=", 255))
Expect(name).To(HaveSuffix("ζ±.zip"))
})
})
})