package naming import ( "path/filepath" "strings" "testing" ) func TestFormatTemplate(t *testing.T) { got := FormatTemplate("{tracknumber:02}. {artist} - {title}{explicit}", map[string]string{ "tracknumber": "3", "artist": "Fleetwood Mac", "title": "Dreams", "explicit": "", }) if got != "03. Fleetwood Mac - Dreams" { t.Fatalf("got %q", got) } } func TestCleanName(t *testing.T) { cfg := Config{RestrictCharacters: false, TruncateTo: 120} tests := []struct { name string in string want string }{ {"trims and replaces separator", " Dreams/Live ", "Dreams_Live"}, {"backslash", `AC\DC`, "AC_DC"}, {"colon", "Album: Deluxe", "Album_ Deluxe"}, {"control chars", "Song\x01\x02", "Song__"}, {"del char", "Song\x7f", "Song_"}, {"empty", "", "Unknown"}, {"whitespace only", " ", "Unknown"}, // Path traversal: these must never be returned verbatim, or the // caller's filepath.Join escapes the download root. {"dotdot", "..", "Unknown"}, {"single dot", ".", "Unknown"}, {"many dots", "....", "Unknown"}, {"dotdot padded", " .. ", "Unknown"}, {"trailing dot", "Album.", "Album"}, {"trailing dots", "Album...", "Album"}, {"leading dot kept", ".hidden", ".hidden"}, // Windows device names must not be used as a bare component. {"reserved CON", "CON", "CON_"}, {"reserved nul lowercase", "nul", "nul_"}, {"reserved COM1", "COM1", "COM1_"}, {"not reserved", "CONCERT", "CONCERT"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := CleanName(tc.in, cfg) if got != tc.want { t.Fatalf("CleanName(%q) = %q, want %q", tc.in, got, tc.want) } }) } } // TestCleanNameNeverEscapesRoot is the property that actually matters: whatever // a provider returns, joining the result onto a root must stay under that root. func TestCleanNameNeverEscapesRoot(t *testing.T) { root := "/music" hostile := []string{ "..", ".", "../..", "../../etc", "..\\..", "/etc/passwd", "....//....", " .. ", "", "...", "C:\\Windows", "\x00evil", "a/../../b", } for _, in := range hostile { for _, restrict := range []bool{false, true} { cfg := Config{RestrictCharacters: restrict, TruncateTo: 120} name := CleanName(in, cfg) if strings.ContainsAny(name, `/\`) { t.Errorf("CleanName(%q) = %q contains a path separator", in, name) } if name == "." || name == ".." { t.Errorf("CleanName(%q) = %q is a relative component", in, name) } joined := filepath.Join(root, name) rel, err := filepath.Rel(root, joined) if err != nil { t.Errorf("CleanName(%q) = %q: Rel failed: %v", in, name, err) continue } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { t.Errorf("CleanName(%q) = %q escapes root: joined=%q rel=%q", in, name, joined, rel) } } } } func TestCleanNameTruncation(t *testing.T) { // TruncateTo counts runes. got := CleanName(strings.Repeat("a", 200), Config{TruncateTo: 10}) if len(got) != 10 { t.Fatalf("rune truncation: got %d chars (%q)", len(got), got) } // A long multi-byte title must still fit the 255-byte component limit, // and must remain valid UTF-8. long := strings.Repeat("日", 200) // 3 bytes each = 600 bytes got = CleanName(long, Config{TruncateTo: 200}) if len(got) > maxNameBytes { t.Fatalf("byte truncation: got %d bytes, want <= %d", len(got), maxNameBytes) } if !utf8Valid(got) { t.Fatalf("truncation split a rune: %q", got) } } func utf8Valid(s string) bool { for _, r := range s { if r == '\uFFFD' { return false } } return true } func TestCleanNameRestrictCharacters(t *testing.T) { got := CleanName("Björk – Jóga", Config{RestrictCharacters: true, TruncateTo: 120}) if strings.ContainsAny(got, "ö–ó") { t.Fatalf("non-ascii survived restrict_characters: %q", got) } } func TestYearFromDate(t *testing.T) { tests := []struct{ in, want string }{ {"1977-02-04", "1977"}, {"1977", "1977"}, {"", "Unknown"}, {"77", "Unknown"}, {"abcd-01-01", "Unknown"}, {"19x7-01-01", "Unknown"}, } for _, tc := range tests { if got := YearFromDate(tc.in); got != tc.want { t.Errorf("YearFromDate(%q) = %q, want %q", tc.in, got, tc.want) } } }