package naming import ( "fmt" "regexp" "strconv" "strings" "unicode" "unicode/utf8" ) type Config struct { RestrictCharacters bool TruncateTo int } var tokenRe = regexp.MustCompile(`\{([a-z_]+)(?::0?(\d+))?\}`) var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F\x7F]`) func FormatTemplate(template string, values map[string]string) string { return tokenRe.ReplaceAllStringFunc(template, func(m string) string { groups := tokenRe.FindStringSubmatch(m) if len(groups) < 2 { return m } key := groups[1] val := values[key] if len(groups) >= 3 && groups[2] != "" { if n, err := strconv.Atoi(val); err == nil { if width, widthErr := strconv.Atoi(groups[2]); widthErr == nil { return fmt.Sprintf("%0*d", width, n) } } } return val }) } // maxNameBytes is the per-component limit on ext4, APFS and most other modern // filesystems. TruncateTo counts runes, so a 120-rune CJK title is ~360 bytes // and would fail with ENAMETOOLONG without this second, byte-based cap. const maxNameBytes = 255 // windowsReserved are device names that cannot be used as a path component on // Windows, with or without an extension. var windowsReserved = map[string]struct{}{ "CON": {}, "PRN": {}, "AUX": {}, "NUL": {}, "COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {}, "LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {}, } // CleanName turns a remote title into a single safe path component. // // Everything it receives is attacker-controlled: album, playlist and track // titles come straight from provider APIs, and on SoundCloud and Tidal they are // user-generated. The result is joined onto the download root by the caller, so // it must never be "." or ".." (which would escape or collapse into the parent // directory) and must never contain a separator. func CleanName(in string, cfg Config) string { s := strings.TrimSpace(in) s = invalidPathRe.ReplaceAllString(s, "_") if cfg.RestrictCharacters { r := make([]rune, 0, len(s)) for _, ch := range s { if ch >= 32 && ch <= 126 { r = append(r, ch) } } s = string(r) } if cfg.TruncateTo > 0 { runes := []rune(s) if len(runes) > cfg.TruncateTo { s = string(runes[:cfg.TruncateTo]) } } s = truncateBytes(s, maxNameBytes) // Trailing dots and spaces are stripped by Windows, which turns "foo." into // "foo" and makes ".." survive as a traversal component. s = strings.TrimRight(s, ". ") s = strings.TrimSpace(s) if s == "" || isAllDots(s) { return "Unknown" } if _, bad := windowsReserved[strings.ToUpper(s)]; bad { return s + "_" } return s } // truncateBytes shortens s to at most max bytes without splitting a rune. func truncateBytes(s string, max int) string { if len(s) <= max { return s } cut := s[:max] for len(cut) > 0 && !utf8.ValidString(cut) { cut = cut[:len(cut)-1] } return cut } // isAllDots reports whether s consists only of "." characters, i.e. "." or // ".." or any longer run that some filesystems also treat as relative. func isAllDots(s string) bool { for _, ch := range s { if ch != '.' { return false } } return len(s) > 0 } func YearFromDate(date string) string { if len(date) >= 4 { prefix := date[:4] for _, ch := range prefix { if !unicode.IsDigit(ch) { return "Unknown" } } return prefix } return "Unknown" }