diff --git a/PROJECT.md b/PROJECT.md index 7098702..9b180cb 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -51,7 +51,8 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する | | 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする | | 動画のマウント先 | web と worker は `VIDEO_DIR`(既定 `/videos`)に同じ NAS 共有をマウントする。`download_jobs.file_path` は `VIDEO_DIR` からの相対パスを保存する | -| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`、各入力要素は UTF-8 で60バイトまでに切り詰める。エピソード ID で同名衝突を避ける | +| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`とする。番組名はUTF-8で90バイト、回タイトルは140バイト、サニタイズ後のエピソードIDは64バイトまでとし、ファイル名全体を255バイト以下に収める。64バイトを超えるIDはworkerで`invalid_output`にする。実際のTVer IDは約10文字で、yt-dlpのextractorも英数字として扱うため64バイトを上限とした。既存ファイルは改名せず、新しい保存から適用する | +| 動画ファイルの境界 | webは`VIDEO_DIR`を`os.OpenRoot`で開き、DBの相対`file_path`を`Root.Open`・`Root.Remove`へ渡す。`VIDEO_DIR`外へのパストラバーサルと外向きシンボリックリンクをOSのディレクトリ境界で拒否する | | 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する | | 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | | 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる | @@ -91,7 +92,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する -- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を使う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクを拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない +- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する - worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う @@ -146,8 +147,6 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ ## 課題 - **DBバックアップは未着手。** コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持の日次処理を用意する -- ファイル名の各入力要素はUTF-8で60バイトに制限しているが、NFSマウントは`namlen=255`のため広げる余地がある。読みやすさとフルパス長も含めて上限を決める -- 動画ファイルのパス検査と操作をGoの`os.Root`へ移し、`VIDEO_DIR`外へのアクセス防止をディレクトリ境界で扱えるか検討する - DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す ## 確かめ方 @@ -158,7 +157,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - TVer 応答のテストは `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない - 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する -- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、相対パスとシンボリックリンクの検査、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は `internal/media` でテストする +- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する - 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する - NAS用Composeは`deploy/nas/tver-db/compose.yaml`をYAMLパーサーで読み、`services.postgres`、環境変数、ポート、保存先、healthcheckを確認する。Dockerが使える環境では`docker compose --env-file deploy/nas/tver-db/.env.example -f deploy/nas/tver-db/compose.yaml config`でも展開結果を確認する diff --git a/internal/media/path.go b/internal/media/path.go index fc5315b..d616395 100644 --- a/internal/media/path.go +++ b/internal/media/path.go @@ -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 diff --git a/internal/media/path_test.go b/internal/media/path_test.go index 648eeea..dc7a609 100644 --- a/internal/media/path_test.go +++ b/internal/media/path_test.go @@ -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) diff --git a/internal/web/library.go b/internal/web/library.go index bc11359..4887c88 100644 --- a/internal/web/library.go +++ b/internal/web/library.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strconv" - "strings" "gitea.kazu634.com/kazu634/tver/internal/store" "github.com/jackc/pgx/v5" @@ -76,12 +75,13 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) { } return } - path, err := secureVideoPath(s.videoDir, *job.FilePath, true) + root, err := os.OpenRoot(s.videoDir) if err != nil { http.NotFound(w, r) return } - file, err := os.Open(path) + defer root.Close() + file, err := root.Open(*job.FilePath) if err != nil { http.NotFound(w, r) return @@ -93,7 +93,7 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "video/mp4") - http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file) + http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file) } func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) { @@ -102,12 +102,24 @@ func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) { return } if job.FilePath != nil { - path, err := secureVideoPath(s.videoDir, *job.FilePath, false) + if !filepath.IsLocal(*job.FilePath) { + http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) + return + } + root, err := os.OpenRoot(s.videoDir) if err != nil { http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) return } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + defer root.Close() + file, err := root.Open(*job.FilePath) + if err == nil { + _ = file.Close() + } else if !errors.Is(err, os.ErrNotExist) { + http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict) + return + } + if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) { s.logger.Printf("動画ファイルの削除に失敗: %v", err) http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError) return @@ -139,57 +151,3 @@ func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job } return job, true } - -func secureVideoPath(root, relative string, mustExist bool) (string, error) { - if relative == "" || filepath.IsAbs(relative) { - return "", errors.New("video path must be relative") - } - clean := filepath.Clean(relative) - if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return "", errors.New("video path leaves root") - } - rootAbs, err := filepath.Abs(root) - if err != nil { - return "", err - } - rootReal, err := filepath.EvalSymlinks(rootAbs) - if err != nil { - return "", err - } - candidate := filepath.Join(rootAbs, clean) - if err := insideRoot(rootAbs, candidate); err != nil { - return "", err - } - resolved, err := filepath.EvalSymlinks(candidate) - if err == nil { - if err := insideRoot(rootReal, resolved); err != nil { - return "", err - } - return candidate, nil - } - if mustExist || !errors.Is(err, os.ErrNotExist) { - return "", err - } - ancestor := filepath.Dir(candidate) - for { - resolvedAncestor, evalErr := filepath.EvalSymlinks(ancestor) - if evalErr == nil { - if err := insideRoot(rootReal, resolvedAncestor); err != nil { - return "", err - } - return candidate, nil - } - if !errors.Is(evalErr, os.ErrNotExist) || ancestor == rootAbs { - return "", evalErr - } - ancestor = filepath.Dir(ancestor) - } -} - -func insideRoot(root, path string) error { - rel, err := filepath.Rel(root, path) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return errors.New("path leaves video root") - } - return nil -} diff --git a/internal/web/library_test.go b/internal/web/library_test.go index efeb680..192c8d4 100644 --- a/internal/web/library_test.go +++ b/internal/web/library_test.go @@ -135,7 +135,23 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) { if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil { t.Fatal(err) } - for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4"} { + relativeOutsideFile, err := filepath.Rel(dir, outsideFile) + if err != nil { + t.Fatal(err) + } + relativeOutsideDir, err := filepath.Rel(dir, outside) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(relativeOutsideFile, filepath.Join(dir, "relative-link.mp4")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(relativeOutsideDir, filepath.Join(dir, "relative-linked-directory")); err != nil { + t.Fatal(err) + } + for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, + "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4", + "relative-link.mp4", "relative-linked-directory/outside.mp4", "relative-linked-directory/missing.mp4"} { jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}} handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil) if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound { @@ -146,6 +162,89 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) { t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs)) } } + if got, err := os.ReadFile(outsideFile); err != nil || string(got) != "secret" { + t.Fatalf("outside target was changed: content=%q err=%v", got, err) + } + for _, link := range []string{"link.mp4", "linked-directory", "relative-link.mp4", "relative-linked-directory"} { + if _, err := os.Lstat(filepath.Join(dir, link)); err != nil { + t.Errorf("rejected link %q was changed: %v", link, err) + } + } +} + +func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.mp4") + if err := os.WriteFile(target, []byte("video"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target.mp4", filepath.Join(dir, "link.mp4")); err != nil { + t.Fatal(err) + } + jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "link.mp4")}} + handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil) + if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" { + t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String()) + } + if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 { + t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs)) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("symlink target was removed: %v", err) + } + if _, err := os.Lstat(filepath.Join(dir, "link.mp4")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("symlink itself remains: %v", err) + } +} + +func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "target"), 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "target", "video.mp4") + if err := os.WriteFile(target, []byte("video"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(dir, "linked-directory")); err != nil { + t.Fatal(err) + } + jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "linked-directory/video.mp4")}} + handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil) + if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" { + t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String()) + } + if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 { + t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs)) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target file remains: %v", err) + } + if _, err := os.Lstat(filepath.Join(dir, "linked-directory")); err != nil { + t.Fatalf("directory symlink was removed: %v", err) + } +} + +func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.mp4") + if err := os.WriteFile(target, []byte("video"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "absolute-link.mp4")); err != nil { + t.Fatal(err) + } + jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "absolute-link.mp4")}} + handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil) + if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound { + t.Fatalf("absolute symlink serve status=%d", got) + } + if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 { + t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs)) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("target was changed: %v", err) + } } func TestDeleteVideoFileThenRecord(t *testing.T) { diff --git a/internal/worker/runner.go b/internal/worker/runner.go index 81f54b8..dc5ff18 100644 --- a/internal/worker/runner.go +++ b/internal/worker/runner.go @@ -172,6 +172,11 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) { return } defer os.RemoveAll(workDir) + relative, err := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID) + if err != nil { + r.failIfRunning(job.ID, "invalid_output", redact(err.Error())) + return + } downloaded, stderr, err := r.download(ctx, job, workDir) if errors.Is(err, errShutdown) { @@ -214,7 +219,6 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) { r.failIfRunning(job.ID, "unknown", "移動状態を記録できません: "+err.Error()) return } - relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID) destination := filepath.Join(r.config.VideoDir, relative) if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { r.failIfRunning(job.ID, "unknown", "保存先を作成できません: "+err.Error()) diff --git a/internal/worker/runner_test.go b/internal/worker/runner_test.go index 4075cc6..ed03d82 100644 --- a/internal/worker/runner_test.go +++ b/internal/worker/runner_test.go @@ -228,6 +228,15 @@ func runningJob() store.Job { return store.Job{ID: 7, EpisodeID: "ep123", SeriesTitle: "番組名", Title: "第1話", Status: "running"} } +func relativeVideoPath(t *testing.T, seriesTitle, episodeTitle, episodeID string) string { + t.Helper() + path, err := media.RelativeVideoPath(seriesTitle, episodeTitle, episodeID) + if err != nil { + t.Fatal(err) + } + return path +} + func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) { t.Setenv("FAKE_YTDLP_MODE", "success") t.Setenv("FAKE_FFPROBE_MODE", "valid") @@ -236,7 +245,7 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) { t.Setenv("FAKE_ARGS_FILE", argsFile) jobs := &fakeStore{job: runningJob()} testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job) - want := filepath.ToSlash(media.RelativeVideoPath("番組名", "第1話", "ep123")) + want := filepath.ToSlash(relativeVideoPath(t, "番組名", "第1話", "ep123")) if !jobs.completed || jobs.completePath != want || jobs.completeSize == 0 { t.Fatalf("complete: done=%v path=%q size=%d", jobs.completed, jobs.completePath, jobs.completeSize) } @@ -273,6 +282,32 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) { } } +func TestProcessJobRejectsLongEpisodeIDAndCleans(t *testing.T) { + dir := t.TempDir() + argsFile := filepath.Join(dir, "yt-dlp-args") + t.Setenv("FAKE_ARGS_FILE", argsFile) + workDir := filepath.Join(dir, ".incoming", "7") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workDir, "stale"), []byte("stale"), 0o644); err != nil { + t.Fatal(err) + } + job := runningJob() + job.EpisodeID = strings.Repeat("i", 65) + jobs := &fakeStore{job: job} + testRunner(t, jobs, dir).ProcessJob(context.Background(), job) + if jobs.completed || jobs.failedKind != "invalid_output" || !strings.Contains(jobs.failedMessage, "64バイト以下") { + t.Fatalf("長いIDの失敗: kind=%q message=%q completed=%v", jobs.failedKind, jobs.failedMessage, jobs.completed) + } + if _, err := os.Stat(argsFile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("yt-dlp が起動された: %v", err) + } + if _, err := os.Stat(workDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("作業ディレクトリが残った: %v", err) + } +} + func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) { t.Setenv("FAKE_YTDLP_MODE", "success") t.Setenv("FAKE_FFPROBE_MODE", "valid") @@ -294,7 +329,7 @@ func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) { if strings.Contains(string(args), job.SeriesTitle) || strings.Contains(string(args), job.Title) { t.Fatalf("番組情報が yt-dlp 引数に入った: %s", args) } - want := filepath.ToSlash(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)) + want := filepath.ToSlash(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)) if jobs.completePath != want { t.Fatalf("保存先=%q want=%q", jobs.completePath, want) } @@ -305,7 +340,7 @@ func TestProcessJobReplacesExistingDestination(t *testing.T) { t.Setenv("FAKE_FFPROBE_MODE", "valid") dir := t.TempDir() job := runningJob() - relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID) + relative := relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID) destination := filepath.Join(dir, relative) if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { t.Fatal(err) @@ -409,7 +444,7 @@ func TestProcessJobDestinationDirectoryFailureCleansIncoming(t *testing.T) { t.Setenv("FAKE_FFPROBE_MODE", "valid") dir := t.TempDir() job := runningJob() - seriesDir := filepath.Join(dir, filepath.Dir(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID))) + seriesDir := filepath.Join(dir, filepath.Dir(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID))) if err := os.WriteFile(seriesDir, []byte("blocks directory"), 0o644); err != nil { t.Fatal(err) }