ファイル名の上限を拡大し、動画の配信・削除を os.Root に置き換え(エピソード ID は 64 バイト上限)

This commit is contained in:
2026-10-04 13:43:07 +09:00
parent f963dea2d1
commit 9b856d63fa
7 changed files with 345 additions and 115 deletions
+36 -15
View File
@@ -1,23 +1,41 @@
package media
import (
"fmt"
"path/filepath"
"strings"
"unicode"
)
const maxComponentRunes = 80
const maxComponentBytes = 60
const (
maxSeriesBytes = 90
maxEpisodeBytes = 140
maxEpisodeIDBytes = 64
maxFilenameBytes = 255
)
// RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) string {
series := sanitizeComponent(seriesTitle)
episode := sanitizeComponent(episodeTitle)
id := sanitizeComponent(episodeID)
return filepath.Join(series, series+" "+episode+" ["+id+"].mp4")
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) (string, error) {
series := sanitizeComponent(seriesTitle, maxSeriesBytes)
episode := sanitizeComponent(episodeTitle, maxEpisodeBytes)
id := sanitizeComponent(episodeID, -1)
if len(id) > maxEpisodeIDBytes {
return "", fmt.Errorf("エピソードIDはサニタイズ後%dバイト以下である必要があります: %dバイト", maxEpisodeIDBytes, len(id))
}
// A directory entry must fit within the NFS namlen limit. Keep the episode
// ID intact and use the remaining bytes for the episode title.
const separators = " [].mp4"
seriesInFilename := series
titleBytes := maxFilenameBytes - len(seriesInFilename) - len(id) - len(separators)
if titleBytes < 0 {
titleBytes = 0
}
episode = truncateComponent(episode, titleBytes, false)
return filepath.Join(series, seriesInFilename+" "+episode+" ["+id+"].mp4"), nil
}
func sanitizeComponent(value string) string {
func sanitizeComponent(value string, maxBytes int) string {
var result []rune
lastReplacement := false
for _, r := range value {
@@ -39,22 +57,25 @@ func sanitizeComponent(value string) string {
if clean == "" || clean == "." || clean == ".." {
return "名称未設定"
}
runes := []rune(clean)
if len(runes) > maxComponentRunes {
runes = runes[:maxComponentRunes]
return truncateComponent(clean, maxBytes, true)
}
func truncateComponent(value string, maxBytes int, fallback bool) string {
if maxBytes < 0 {
return value
}
var limited []rune
bytes := 0
for _, r := range runes {
for _, r := range value {
size := len(string(r))
if bytes+size > maxComponentBytes {
if bytes+size > maxBytes {
break
}
limited = append(limited, r)
bytes += size
}
clean = strings.Trim(string(limited), " .")
if clean == "" || clean == "." || clean == ".." {
clean := strings.Trim(string(limited), " .")
if fallback && (clean == "" || clean == "." || clean == "..") {
return "名称未設定"
}
return clean
+143 -30
View File
@@ -1,6 +1,7 @@
package media
import (
"fmt"
"path/filepath"
"strings"
"testing"
@@ -9,19 +10,28 @@ import (
)
func TestRelativeVideoPath(t *testing.T) {
got := RelativeVideoPath("番組名", "第1話", "ep123")
got, err := RelativeVideoPath("番組名", "第1話", "ep123")
if err != nil {
t.Fatal(err)
}
want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4")
if got != want {
t.Fatalf("path=%q want=%q", got, want)
}
other := RelativeVideoPath("番組名", "第1話", "ep456")
other, err := RelativeVideoPath("番組名", "第1話", "ep456")
if err != nil {
t.Fatal(err)
}
if got == other || !strings.Contains(other, "[ep456]") {
t.Fatalf("ID による衝突回避: %q %q", got, other)
}
}
func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
got := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
got, err := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
if err != nil {
t.Fatal(err)
}
if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") {
t.Fatalf("禁止文字が残った: %q", got)
}
@@ -38,27 +48,122 @@ func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) {
for _, name := range []string{"", " ", ".", "..", "..."} {
got := RelativeVideoPath(name, name, "")
got, err := RelativeVideoPath(name, name, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "名称未設定") {
t.Errorf("空の代替名 %q: %q", name, got)
}
}
long := strings.Repeat("界", 200)
got := RelativeVideoPath(long, long, "id")
got, err := RelativeVideoPath(long, long, "id")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if len(parts) != 2 || len([]byte(parts[0])) != 60 || !utf8.ValidString(parts[0]) {
t.Fatalf("番組名の60バイト境界: %q (%d bytes)", parts[0], len([]byte(parts[0])))
if len(parts) != 2 || len([]byte(parts[0])) != maxSeriesBytes || !utf8.ValidString(parts[0]) {
t.Fatalf("番組名の%dバイト境界: %q (%d bytes)", maxSeriesBytes, parts[0], len([]byte(parts[0])))
}
for _, part := range strings.Split(filepath.ToSlash(got), "/") {
if len([]byte(part)) > 240 {
t.Fatalf("長すぎる要素: %d bytes", len([]byte(part)))
}
if !utf8.ValidString(part) {
t.Fatalf("不正な UTF-8: %q", part)
}
if len([]byte(parts[1])) > maxFilenameBytes || !utf8.ValidString(parts[1]) {
t.Fatalf("ファイル名の上限: %q (%d bytes)", parts[1], len([]byte(parts[1])))
}
if got := sanitizeComponent(strings.Repeat("a", 61)); len([]byte(got)) != 60 {
t.Fatalf("ASCII の60バイト境界: %d %q", len([]byte(got)), got)
if got := sanitizeComponent(strings.Repeat("a", maxEpisodeBytes+1), maxEpisodeBytes); len([]byte(got)) != maxEpisodeBytes {
t.Fatalf("ASCII の%dバイト境界: %d %q", maxEpisodeBytes, len([]byte(got)), got)
}
}
func TestRelativeVideoPathEpisodeIDLimit(t *testing.T) {
id := strings.Repeat("i", maxEpisodeIDBytes)
got, err := RelativeVideoPath(strings.Repeat("s", maxSeriesBytes), strings.Repeat("t", maxEpisodeBytes), id)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if len(parts) != 2 || len([]byte(parts[1])) > maxFilenameBytes || !strings.Contains(parts[1], "["+id+"]") {
t.Fatalf("64バイトIDのファイル名=%q", got)
}
if _, err := RelativeVideoPath("series", "episode", strings.Repeat("i", maxEpisodeIDBytes+1)); err == nil {
t.Fatal("65バイトのエピソードIDを受理した")
}
}
func TestRelativeVideoPathUsesFilenameBudgetInRequiredOrder(t *testing.T) {
series := strings.Repeat("s", maxSeriesBytes)
title := strings.Repeat("t", maxEpisodeBytes)
for _, tt := range []struct {
name string
idBytes int
wantSeriesInFile int
wantEpisodeInFile int
wantFilenameBytes int
}{
{"ちょうど255バイト", 17, 90, 140, 255},
{"回タイトルから1バイト削る", 18, 90, 139, 255},
{"64バイトIDでも番組名を維持", 64, 90, 93, 255},
} {
t.Run(tt.name, func(t *testing.T) {
id := strings.Repeat("i", tt.idBytes)
path, err := RelativeVideoPath(series, title, id)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) != 2 || parts[0] != series {
t.Fatalf("ディレクトリ名=%q", parts)
}
wantFilename := strings.Repeat("s", tt.wantSeriesInFile) + " " + strings.Repeat("t", tt.wantEpisodeInFile) + " [" + id + "].mp4"
if parts[1] != wantFilename || len([]byte(parts[1])) != tt.wantFilenameBytes {
t.Fatalf("filename=%q (%d bytes) want=%q (%d bytes)", parts[1], len([]byte(parts[1])), wantFilename, tt.wantFilenameBytes)
}
})
}
}
func TestRelativeVideoPathEpisodeIDNearAndBeyondFilenameLimit(t *testing.T) {
for _, idBytes := range []int{64, 65, 248, 255, 256} {
t.Run(fmt.Sprintf("id_%d_bytes", idBytes), func(t *testing.T) {
id := strings.Repeat("i", idBytes)
path, err := RelativeVideoPath("series", "episode", id)
if idBytes > 64 {
if err == nil || path != "" {
t.Fatalf("%dバイトID: path=%q err=%v, want error", idBytes, path, err)
}
if !strings.Contains(err.Error(), "64バイト以下") {
t.Fatalf("%dバイトIDのエラー理由=%q", idBytes, err)
}
return
}
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) != 2 {
t.Fatalf("parts=%q", parts)
}
if !strings.Contains(parts[1], "["+id+"]") {
t.Fatalf("episode ID was changed: %q", parts[1])
}
if got := len([]byte(parts[1])); got > maxFilenameBytes {
t.Fatalf("episode ID %d bytes produced %d-byte filename above %d: %q", idBytes, got, maxFilenameBytes, parts[1])
}
if !utf8.ValidString(parts[1]) {
t.Fatalf("filename is not valid UTF-8: %q", parts[1])
}
})
}
}
func TestRelativeVideoPathHasNoRuneLimit(t *testing.T) {
series := strings.Repeat("a", 90)
title := strings.Repeat("b", 100)
got, err := RelativeVideoPath(series, title, "id")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if parts[0] != series || !strings.Contains(parts[1], " "+title+" [id].mp4") {
t.Fatalf("80 rune limit remains: %q", got)
}
}
@@ -69,11 +174,11 @@ func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) {
{"前後の空白", " \t 番組名\n ", "_ 番組名_"},
{"空白のみ", "   ", "名称未設定"},
{"制御文字のみ", "\t\n", "_"},
{"多数の先頭空白", strings.Repeat(" ", maxComponentRunes+10) + "番組名", "番組名"},
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 70), strings.Repeat("a", 60)},
{"多数の先頭空白", strings.Repeat(" ", 100) + "番組名", "番組名"},
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 100), strings.Repeat("a", maxSeriesBytes)},
} {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeComponent(tt.input); got != tt.want {
if got := sanitizeComponent(tt.input, maxSeriesBytes); got != tt.want {
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
}
})
@@ -84,14 +189,14 @@ func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) {
for _, tt := range []struct {
name, input, want string
}{
{"60バイト目が空白", strings.Repeat("a", 59) + " " + "b", strings.Repeat("a", 59)},
{"60バイト目がドット", strings.Repeat("a", 59) + "." + "b", strings.Repeat("a", 59)},
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 59) + "b", "a"},
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 59) + "b", "a"},
{"複数バイト文字の直後がドット", strings.Repeat("界", 19) + ".続", strings.Repeat("界", 19)},
{"90バイト目が空白", strings.Repeat("a", 89) + " " + "b", strings.Repeat("a", 89)},
{"90バイト目がドット", strings.Repeat("a", 89) + "." + "b", strings.Repeat("a", 89)},
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 89) + "b", "a"},
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 89) + "b", "a"},
{"複数バイト文字の直後がドット", strings.Repeat("界", 29) + ".続", strings.Repeat("界", 29)},
} {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeComponent(tt.input)
got := sanitizeComponent(tt.input, maxSeriesBytes)
if got != tt.want {
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
}
@@ -117,15 +222,15 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}),
}
for i, input := range inputs {
got := sanitizeComponent(input)
got := sanitizeComponent(input, maxEpisodeBytes)
if got == "" || got == "." || got == ".." {
t.Errorf("case %d: 空またはdot segment: %q", i, got)
}
if strings.Trim(got, " .") != got {
t.Errorf("case %d: 前後に空白またはdot: %q", i, got)
}
if len([]byte(got)) > maxComponentBytes || utf8.RuneCountInString(got) > maxComponentRunes {
t.Errorf("case %d: 上限超過: bytes=%d runes=%d %q", i, len([]byte(got)), utf8.RuneCountInString(got), got)
if len([]byte(got)) > 140 {
t.Errorf("case %d: 上限超過: bytes=%d %q", i, len([]byte(got)), got)
}
if !utf8.ValidString(got) {
t.Errorf("case %d: 不正なUTF-8: %q", i, got)
@@ -138,14 +243,22 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got)
}
}
if again := sanitizeComponent(got); again != got {
if again := sanitizeComponent(got, maxEpisodeBytes); again != got {
t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again)
}
path := RelativeVideoPath(input, input, input)
path, err := RelativeVideoPath(input, input, "id")
if err != nil {
t.Errorf("case %d: パス生成エラー: %v", i, err)
continue
}
parts := strings.Split(filepath.ToSlash(path), "/")
if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") {
t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts)
continue
}
if len([]byte(parts[0])) > 90 || len([]byte(parts[1])) > 255 {
t.Errorf("case %d: パス要素の上限超過: dir=%d filename=%d path=%q", i, len([]byte(parts[0])), len([]byte(parts[1])), path)
}
if !utf8.ValidString(path) {
t.Errorf("case %d: パスが不正なUTF-8: %q", i, path)