unarr/internal/engine/hls_test.go
Deivid Soto c148cb8ce7 fix(security): harden HLS session IDs, /health disclosure, archive password handling
Phase 1 security audit follow-up:

- Reject HLS session IDs that aren't safe filesystem components
  (regex allowlist) to defend against path traversal via a buggy or
  compromised server. Applied at StartHLSSession and at the /hls URL
  handler; invalid IDs share the 404 of unknown sessions so the
  accepted format isn't enumerable.
- /health no longer leaks the active filename, taskID prefix or client
  IP to non-loopback callers. Uses net.IP.IsLoopback so IPv4-mapped
  IPv6 (::ffff:127.0.0.1) is recognised and the empty-string parse
  failure stops bypassing the boundary.
- unrar/7z passwords now travel through stdin instead of -p<password>
  in argv, removing /proc/<pid>/cmdline disclosure. Control characters
  in the password are rejected up front so a hostile NZB cannot feed
  extra prompt answers. Both invocations are bounded by a 30-minute
  context to stop indefinite hangs if the tool ever decides to prompt.
2026-05-15 17:10:42 +02:00

293 lines
7.6 KiB
Go

package engine
import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestYnBool(t *testing.T) {
if got := ynBool(true); got != "YES" {
t.Errorf("ynBool(true) = %q, want YES", got)
}
if got := ynBool(false); got != "NO" {
t.Errorf("ynBool(false) = %q, want NO", got)
}
}
func TestBitrateForQuality(t *testing.T) {
cases := map[string]int{
"2160p": 25_000_000,
"1080p": 6_000_000,
"720p": 3_500_000,
"480p": 1_500_000,
"unknown": 6_000_000,
"": 6_000_000,
}
for q, want := range cases {
if got := bitrateForQuality(q); got != want {
t.Errorf("bitrateForQuality(%q) = %d, want %d", q, got, want)
}
}
}
func TestQualityHeight(t *testing.T) {
cases := map[string]int{
"2160p": 2160,
"1080p": 1080,
"720p": 720,
"480p": 480,
"": 0,
"unknown": 0,
}
for q, want := range cases {
if got := qualityHeight(q); got != want {
t.Errorf("qualityHeight(%q) = %d, want %d", q, got, want)
}
}
}
func TestScaledDimensions(t *testing.T) {
tests := []struct {
name string
srcW, srcH, capH int
wantW, wantH int
}{
{"no_cap_returns_source", 1920, 1080, 0, 1920, 1080},
{"under_cap_returns_source", 1280, 720, 1080, 1280, 720},
{"4k_capped_to_1080", 3840, 2160, 1080, 1920, 1080},
{"even_width_stays_even", 1003, 750, 720, 962, 720},
{"odd_width_bumps_up", 1001, 700, 500, 716, 500},
{"invalid_returns_default", 0, 0, 0, 1920, 1080},
{"negative_returns_default", -10, 100, 0, 1920, 1080},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotW, gotH := scaledDimensions(tt.srcW, tt.srcH, tt.capH)
if gotW != tt.wantW || gotH != tt.wantH {
t.Errorf("scaledDimensions(%d,%d,%d) = (%d,%d), want (%d,%d)",
tt.srcW, tt.srcH, tt.capH, gotW, gotH, tt.wantW, tt.wantH)
}
})
}
}
func TestShortHLSID(t *testing.T) {
if got := shortHLSID("abcdef1234567890"); got != "abcdef12" {
t.Errorf("got %q, want abcdef12", got)
}
if got := shortHLSID("short"); got != "short" {
t.Errorf("got %q, want short", got)
}
if got := shortHLSID(""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestHlsTmpDirRoot(t *testing.T) {
root := hlsTmpDirRoot()
if root == "" {
t.Fatal("hlsTmpDirRoot returned empty")
}
if !strings.Contains(root, "hls-sessions") && !strings.Contains(root, "unarr-hls-sessions") {
t.Errorf("expected path to contain hls-sessions, got %q", root)
}
}
func TestRenderVideoPlaylist(t *testing.T) {
out := renderVideoPlaylist(10.0, 3)
required := []string{
"#EXTM3U",
"#EXT-X-VERSION:7",
"#EXT-X-PLAYLIST-TYPE:VOD",
`#EXT-X-MAP:URI="init.mp4"`,
"seg-0.m4s",
"seg-1.m4s",
"seg-2.m4s",
"#EXT-X-ENDLIST",
}
for _, want := range required {
if !strings.Contains(out, want) {
t.Errorf("playlist missing %q\n%s", want, out)
}
}
}
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
out := renderVideoPlaylist(9.5, 3)
if !strings.Contains(out, "#EXTINF:1.500,") {
t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
}
}
func TestRenderMasterPlaylist(t *testing.T) {
probe := &StreamProbe{
Width: 1920,
Height: 1080,
SubtitleTracks: []ProbeSubtitleTrack{
{Index: 0, Lang: "es", Codec: "subrip", Title: "Spanish"},
{Index: 1, Lang: "en", Codec: "subrip", Title: "English", Forced: true},
{Index: 2, Lang: "ja", Codec: "hdmv_pgs_subtitle"}, // bitmap, skipped
},
}
out := renderMasterPlaylist(probe, "1080p")
if !strings.HasPrefix(out, "#EXTM3U") {
t.Errorf("must start with #EXTM3U, got:\n%s", out)
}
if !strings.Contains(out, "BANDWIDTH=6000000") {
t.Errorf("expected 1080p bandwidth, got:\n%s", out)
}
if !strings.Contains(out, "RESOLUTION=1920x1080") {
t.Errorf("expected 1920x1080 resolution, got:\n%s", out)
}
if !strings.Contains(out, `SUBTITLES="subs"`) {
t.Errorf("expected subtitles group attached, got:\n%s", out)
}
if !strings.Contains(out, `LANGUAGE="es"`) || !strings.Contains(out, `LANGUAGE="en"`) {
t.Errorf("expected text subs included, got:\n%s", out)
}
if strings.Contains(out, "hdmv_pgs") || strings.Contains(out, `LANGUAGE="ja"`) {
t.Errorf("bitmap subs should be excluded, got:\n%s", out)
}
if !strings.Contains(out, "(forced)") {
t.Errorf("expected forced suffix on English track, got:\n%s", out)
}
}
func TestRenderMasterPlaylistNoSubs(t *testing.T) {
probe := &StreamProbe{Width: 1280, Height: 720}
out := renderMasterPlaylist(probe, "720p")
if strings.Contains(out, "SUBTITLES=") {
t.Errorf("no subs should produce no SUBTITLES attr, got:\n%s", out)
}
if !strings.Contains(out, "BANDWIDTH=3500000") {
t.Errorf("expected 720p bandwidth, got:\n%s", out)
}
}
func TestHLSSessionRegistry(t *testing.T) {
r := NewHLSSessionRegistry()
if r.Get("missing") != nil {
t.Error("Get on empty registry should return nil")
}
s1 := &HLSSession{cfg: HLSSessionConfig{SessionID: "a"}, lastTouch: time.Now()}
r.Register(s1)
if got := r.Get("a"); got != s1 {
t.Errorf("Get(a) = %v, want %v", got, s1)
}
// Registering a different session evicts (and Closes) the previous one.
s2 := &HLSSession{cfg: HLSSessionConfig{SessionID: "b"}, lastTouch: time.Now()}
r.Register(s2)
if r.Get("a") != nil {
t.Error("registering different session should evict prior entries")
}
if r.Get("b") != s2 {
t.Error("Get(b) should return s2")
}
r.Remove("b")
if r.Get("b") != nil {
t.Error("Remove should drop the session")
}
}
func TestHLSSessionAccessors(t *testing.T) {
probe := &StreamProbe{VideoCodec: "h264", Width: 1280, Height: 720}
s := &HLSSession{
cfg: HLSSessionConfig{SessionID: "abcdef1234"},
probe: probe,
manifestRoot: "MASTER",
manifestVideo: "VIDEO",
durationSec: 42.5,
lastTouch: time.Now().Add(-1 * time.Hour),
}
if s.MasterPlaylist() != "MASTER" {
t.Errorf("MasterPlaylist mismatch")
}
if s.VideoPlaylist() != "VIDEO" {
t.Errorf("VideoPlaylist mismatch")
}
if s.DurationSeconds() != 42.5 {
t.Errorf("DurationSeconds mismatch")
}
if s.Probe() != probe {
t.Errorf("Probe mismatch")
}
old := s.lastTouch
s.Touch()
if !s.lastTouch.After(old) {
t.Errorf("Touch did not advance lastTouch")
}
info := s.ProbeInfo()
if info["videoCodec"] != "h264" || info["width"] != 1280 {
t.Errorf("ProbeInfo missing fields: %v", info)
}
}
func TestHLSSessionProbeInfoNil(t *testing.T) {
s := &HLSSession{}
info := s.ProbeInfo()
if len(info) != 0 {
t.Errorf("nil probe should produce empty info, got %v", info)
}
}
func TestSweepIdle(t *testing.T) {
r := NewHLSSessionRegistry()
idleSession := &HLSSession{
cfg: HLSSessionConfig{SessionID: "old"},
lastTouch: time.Now().Add(-2 * hlsSessionTTL),
}
r.Register(idleSession)
if got := r.SweepIdle(); got != 1 {
t.Errorf("SweepIdle = %d, want 1", got)
}
if r.Get("old") != nil {
t.Errorf("idle session should have been removed")
}
}
func TestCleanupHLSOrphanDirsMissingRoot(t *testing.T) {
// Directory does not exist — should not error.
t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "nonexistent"))
if err := CleanupHLSOrphanDirs(); err != nil {
t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err)
}
}
func TestValidSessionID(t *testing.T) {
good := []string{
"abc",
"7b8c4f12-9d3e-4a1b-9c2f-aabbccddeeff",
"ABC_123-xyz",
strings.Repeat("a", 128),
}
bad := []string{
"",
"../etc/passwd",
"foo/bar",
"foo\\bar",
"foo.bar",
"with spaces",
"with\nnewline",
strings.Repeat("a", 129),
"héctor", // non-ascii
}
for _, id := range good {
if !validSessionID.MatchString(id) {
t.Errorf("validSessionID rejected good id %q", id)
}
}
for _, id := range bad {
if validSessionID.MatchString(id) {
t.Errorf("validSessionID accepted bad id %q", id)
}
}
}