package audio_test import ( "context" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/audio" "git.d-ma.be/mathias/tapir/internal/uploads" ) func TestParseProbe(t *testing.T) { tests := []struct { name string json string want uploads.Probe }{ { name: "audio-only m4a", json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`, want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true}, }, { name: "mp4 with a real video stream", json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`, want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true}, }, { name: "mp3 whose only video stream is attached cover art is NOT video", json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`, want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true}, }, { name: "no streams at all", json: `{"streams":[],"format":{"duration":"10"}}`, want: uploads.Probe{Duration: 10 * time.Second}, }, { name: "duration absent means zero, which Validate rejects as unreadable", json: `{"streams":[{"codec_type":"audio"}],"format":{}}`, want: uploads.Probe{HasAudio: true}, }, { name: "duration reported as N/A means zero", json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`, want: uploads.Probe{HasAudio: true}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := audio.ParseProbe([]byte(tt.json)) require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestParseProbe_GarbageIsAnError(t *testing.T) { _, err := audio.ParseProbe([]byte("not json")) assert.Error(t, err) } // stub writes an executable shell script standing in for ffprobe. This is a fake // at the process boundary: the real binary is not on every host (it is not on // koala), and what is under test is how Probe drives and interprets a process. func stub(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "ffprobe") require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755)) return p } func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) { bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`) got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored") require.NoError(t, err) assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got) } func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) { out := filepath.Join(t.TempDir(), "args") bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+` echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`) _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123") require.NoError(t, err) got, err := os.ReadFile(out) require.NoError(t, err) assert.Equal(t, "/spool/upload-123", string(got)) } func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) { bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`) _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x") assert.ErrorIs(t, err, uploads.ErrUnreadable) } func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) { _, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x") require.Error(t, err) assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a missing ffprobe must not be reported to the user as their file being bad") } func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) { bin := stub(t, `sleep 30`) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() _, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x") require.Error(t, err) assert.Less(t, time.Since(start), 5*time.Second) assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file") } // argvStub records the arguments it was called with into a file and then behaves as // a successful ffprobe, so a test can assert exactly what the process was asked. func argvStub(t *testing.T) (bin, argvFile string) { t.Helper() argvFile = filepath.Join(t.TempDir(), "argv") bin = stub(t, `printf '%s\n' "$@" > `+argvFile+` echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`) return bin, argvFile } func argvOf(t *testing.T, file string) []string { t.Helper() raw, err := os.ReadFile(file) require.NoError(t, err) return strings.Split(strings.TrimSpace(string(raw)), "\n") } func indexOf(args []string, want string) int { for i, a := range args { if a == want { return i } } return -1 } func TestFFprobeProbe_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) { // The input is attacker-supplied. ffmpeg can be told by a file's CONTENT to open // other files or URLs (HLS and concat playlists); recent versions refuse by // default, but that is version luck. Pin it: local files and pipes only, and // only the demuxers real audio recordings use. bin, argvFile := argvStub(t) _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1") require.NoError(t, err) args := argvOf(t, argvFile) pw := indexOf(args, "-protocol_whitelist") require.GreaterOrEqual(t, pw, 0, "argv: %v", args) assert.Equal(t, "file,pipe", args[pw+1]) fw := indexOf(args, "-format_whitelist") require.GreaterOrEqual(t, fw, 0, "argv: %v", args) assert.Equal(t, audio.AllowedDemuxers, args[fw+1]) assert.Equal(t, "/spool/upload-1", args[len(args)-1], "the path stays the last argument") } func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) { for _, bad := range []string{"concat", "hls", "applehttp", "dash", "rtsp", "sdp", "image2"} { assert.NotContains(t, strings.Split(audio.AllowedDemuxers, ","), bad, "%s can make ffmpeg open other files or URLs named inside the upload", bad) } for _, good := range []string{"wav", "mov", "mp4", "m4a", "mp3", "ogg", "matroska", "flac"} { assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good) } } func TestFFprobeProbe_CapsTheStreamCountBeforeTheInput(t *testing.T) { // A 1.3 MB file declaring 900 streams cost ~570 MB of RSS in ffprobe (#49) against // a 512 Mi pod. -max_streams is an input option: it must precede the path. bin, argvFile := argvStub(t) _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1") require.NoError(t, err) args := argvOf(t, argvFile) ms := indexOf(args, "-max_streams") require.GreaterOrEqual(t, ms, 0, "argv: %v", args) assert.Equal(t, "32", args[ms+1]) assert.Less(t, ms, len(args)-1, "must come before the path, which stays last") } func TestFFprobeProbe_AsksOnlyForTheFieldsParseProbeReads(t *testing.T) { // -show_format/-show_streams print every tag: a 120 MB WAV produced 491 MB of JSON // that cmd.Output() buffers in Go (#49). bin, argvFile := argvStub(t) _, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1") require.NoError(t, err) args := argvOf(t, argvFile) assert.Equal(t, -1, indexOf(args, "-show_format"), "argv: %v", args) assert.Equal(t, -1, indexOf(args, "-show_streams"), "argv: %v", args) se := indexOf(args, "-show_entries") require.GreaterOrEqual(t, se, 0, "argv: %v", args) assert.Equal(t, "format=duration:stream=codec_type:stream_disposition=attached_pic", args[se+1]) }