AkurAI Build
Menu

BifrOSt-Apps

public

Latest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson

// SPDX-License-Identifier: MIT

use gst::prelude::*;
use iced_video_player::{Error as VideoError, Video, gst, gst_app};
use std::time::{Duration, Instant};
use url::Url;

const INVALID_MEDIA_URL: &str = "The media address is invalid.";
const UNSUPPORTED_MEDIA_SOURCE: &str = "This media source is not supported.";
const PLAYBACK_START_FAILED: &str =
    "The stream could not be opened. Check the connection and media support.";
const PREVIEW_TIMED_OUT: &str = "The live preview timed out before a frame was available.";
const PREVIEW_CAPTURE_BUDGET: Duration = Duration::from_secs(12);

// Live resolver responses use short-lived hosts such as ed4.cdn.ruv.is, while
// fallback live and VOD streams use two fixed Akamai tenants. Accept only the
// RÚV-owned CDN suffix and those exact external tenants.
const MEDIA_HOSTS: &[&str] = &["ruvlive.akamaized.net", "ruv-vod.akamaized.net"];

#[derive(Debug)]
pub struct PlayerState {
    pub video: Option<Video>,
    pub paused: bool,
    pub muted: bool,
    pub volume: f64,
    pub error: Option<String>,
}

impl Default for PlayerState {
    fn default() -> Self {
        Self {
            video: None,
            paused: false,
            muted: false,
            volume: 1.0,
            error: None,
        }
    }
}

impl PlayerState {
    /// Adopts a pipeline that `prepare_video` built off the UI thread
    /// (APP-5.1), replacing whatever was playing.
    pub fn attach(&mut self, mut video: Video) {
        // Stop and discard the old pipeline before replacing it. A new
        // stream must never leave an earlier one playing invisibly.
        self.clear();

        video.set_volume(self.volume);
        video.set_muted(self.muted);
        video.set_paused(false);

        self.video = Some(video);
        self.paused = false;
        self.error = None;
    }

    pub fn clear(&mut self) {
        // Video's Drop implementation moves its GStreamer pipeline to Null
        // and then joins its frame worker; hand it to a background thread so
        // the UI executor never blocks on teardown (APP-5.1).
        if let Some(video) = self.video.take() {
            discard_video(video);
        }
        self.paused = false;
        self.error = None;
    }

    pub fn toggle_pause(&mut self) {
        let Some(video) = self.video.as_mut() else {
            self.paused = false;
            return;
        };

        if video.eos() {
            if video.restart_stream().is_ok() {
                self.paused = false;
            }
            return;
        }

        self.paused = !self.paused;
        video.set_paused(self.paused);
    }

    pub fn toggle_mute(&mut self) {
        self.muted = !self.muted;
        if let Some(video) = self.video.as_mut() {
            video.set_muted(self.muted);
        }
    }

    pub fn set_volume(&mut self, volume: f64) {
        self.volume = if volume.is_nan() {
            0.0
        } else {
            volume.clamp(0.0, 1.0)
        };

        if let Some(video) = self.video.as_mut() {
            video.set_volume(self.volume);
        }
    }

    pub fn position_seconds(&self) -> f64 {
        self.video
            .as_ref()
            .map_or(0.0, |video| video.position().as_secs_f64())
    }

    pub fn duration_seconds(&self) -> f64 {
        self.video
            .as_ref()
            .map_or(0.0, |video| video.duration().as_secs_f64())
    }

    pub fn seek_to(&mut self, seconds: f64) {
        let Some(video) = self.video.as_mut() else {
            return;
        };
        let duration = video.duration().as_secs_f64();
        if !seconds.is_finite() || duration <= 0.0 {
            return;
        }
        let position = seconds.clamp(0.0, duration);
        let _ = video.seek(Duration::from_secs_f64(position), false);
    }

    pub fn skip_by(&mut self, seconds: f64) {
        self.seek_to(self.position_seconds() + seconds);
    }

    pub fn mark_ended(&mut self) {
        self.paused = true;
    }

    pub fn fail_playback(&mut self) {
        self.clear();
        self.error = Some(PLAYBACK_START_FAILED.to_owned());
    }

    pub fn video(&self) -> Option<&Video> {
        self.video.as_ref()
    }
}

impl Drop for PlayerState {
    fn drop(&mut self) {
        // Make teardown explicit rather than relying on field drop order.
        drop(self.video.take());
    }
}

/// Validates the source and builds the GStreamer pipeline. Pipeline
/// construction blocks; run this on a blocking executor thread, never on the
/// UI executor (APP-5.1).
pub fn prepare_video(source: &str) -> Result<Video, String> {
    let uri = validate_media_url(source).map_err(str::to_owned)?;
    open_video(&uri).map_err(|error| {
        eprintln!("RÚV playback initialization failed: {error}");
        PLAYBACK_START_FAILED.to_owned()
    })
}

/// Drops a video pipeline on a detached background thread so GStreamer
/// teardown and the frame-worker join never stall the UI (APP-5.1).
pub fn discard_video(video: Video) {
    std::thread::spawn(move || drop(video));
}

fn open_video(uri: &Url) -> Result<Video, VideoError> {
    gst::init()?;
    let pipeline = format!(
        "playbin3 uri=\"{}\" text-sink=\"appsink name=iced_text sync=true caps=text/x-raw\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"",
        uri.as_str()
    );
    let pipeline = gst::parse::launch(&pipeline)?
        .downcast::<gst::Pipeline>()
        .map_err(|_| VideoError::Cast)?;
    let video_sink = appsink_from_property(&pipeline, "video-sink", "iced_video")?;
    let text_sink = pipeline
        .property::<gst::Element>("text-sink")
        .downcast::<gst_app::AppSink>()
        .map_err(|_| VideoError::AppSink("iced_text".to_owned()))?;

    Video::from_gst_pipeline(pipeline, video_sink, Some(text_sink))
}

#[derive(Clone, Debug)]
pub struct PreviewFrame {
    pub width: u32,
    pub height: u32,
    pub pixels: Vec<u8>,
}

/// Resets the preview pipeline to `Null` on every exit path, including
/// early returns and panics, so a failed capture never leaks a running
/// GStreamer pipeline.
struct PipelineGuard(gst::Pipeline);

impl Drop for PipelineGuard {
    fn drop(&mut self) {
        let _ = self.0.set_state(gst::State::Null);
    }
}

pub fn capture_live_preview(source: &str) -> Result<PreviewFrame, String> {
    let uri = validate_media_url(source).map_err(str::to_owned)?;
    gst::init().map_err(|error| error.to_string())?;
    let pipeline = format!(
        "playbin3 uri=\"{}\" audio-sink=\"fakesink sync=false\" text-sink=\"fakesink sync=false\" video-sink=\"videoscale ! videoconvert ! appsink name=preview caps=video/x-raw,format=RGBA,width=640,height=360,pixel-aspect-ratio=1/1\"",
        uri.as_str()
    );
    let pipeline = gst::parse::launch(&pipeline)
        .map_err(|error| error.to_string())?
        .downcast::<gst::Pipeline>()
        .map_err(|_| "The preview pipeline has an unexpected type.".to_owned())?;
    let sink = appsink_from_property(&pipeline, "video-sink", "preview")
        .map_err(|error| error.to_string())?;

    let guard = PipelineGuard(pipeline);
    let pipeline = &guard.0;
    let deadline = Instant::now() + PREVIEW_CAPTURE_BUDGET;

    pipeline
        .set_state(gst::State::Playing)
        .map_err(|error| error.to_string())?;

    // Poll instead of blocking: `pull_sample()` without a timeout can hang
    // forever on a stalled live stream. Every wait slice is clamped to the
    // remaining budget so the total capture time is strictly bounded, and
    // bus errors are surfaced as soon as they appear.
    let sample = loop {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            return Err(PREVIEW_TIMED_OUT.to_owned());
        }
        let slice = remaining.min(Duration::from_millis(250));
        let timeout = gst::ClockTime::from_nseconds(slice.as_nanos() as u64);
        if let Some(sample) = sink.try_pull_sample(timeout) {
            break sample;
        }
        if sink.is_eos() {
            return Err(pipeline_error(pipeline));
        }
        if let Some(error) = bus_error(pipeline) {
            return Err(error);
        }
    };

    let caps = sample
        .caps()
        .ok_or_else(|| "The preview frame has no media capabilities.".to_owned())?;
    let structure = caps
        .structure(0)
        .ok_or_else(|| "The preview frame has invalid media capabilities.".to_owned())?;
    let width = structure
        .get::<i32>("width")
        .map_err(|_| "The preview width is unavailable.".to_owned())?;
    let height = structure
        .get::<i32>("height")
        .map_err(|_| "The preview height is unavailable.".to_owned())?;
    let width = u32::try_from(width).map_err(|_| "The preview width is invalid.".to_owned())?;
    let height = u32::try_from(height).map_err(|_| "The preview height is invalid.".to_owned())?;
    let buffer = sample
        .buffer()
        .ok_or_else(|| "The preview frame has no pixel buffer.".to_owned())?;
    let map = buffer
        .map_readable()
        .map_err(|_| "The preview frame could not be read.".to_owned())?;
    let expected = width as usize * height as usize * 4;
    if map.as_slice().len() != expected {
        return Err("The preview frame has an unexpected pixel layout.".to_owned());
    }
    Ok(PreviewFrame {
        width,
        height,
        pixels: map.as_slice().to_vec(),
    })
}

fn bus_error(pipeline: &gst::Pipeline) -> Option<String> {
    let bus = pipeline.bus()?;
    for message in bus.iter_timed(gst::ClockTime::ZERO) {
        if let gst::MessageView::Error(error) = message.view() {
            return Some(format!(
                "The live preview pipeline failed: {}",
                error.error()
            ));
        }
    }
    None
}

fn pipeline_error(pipeline: &gst::Pipeline) -> String {
    bus_error(pipeline)
        .unwrap_or_else(|| "The live preview ended before a frame was available.".to_owned())
}

fn appsink_from_property(
    pipeline: &gst::Pipeline,
    property: &str,
    name: &str,
) -> Result<gst_app::AppSink, VideoError> {
    pipeline
        .property::<gst::Element>(property)
        .pads()
        .first()
        .and_then(|pad| pad.clone().dynamic_cast::<gst::GhostPad>().ok())
        .and_then(|pad| pad.parent_element())
        .and_then(|element| element.downcast::<gst::Bin>().ok())
        .and_then(|bin| bin.by_name(name))
        .and_then(|element| element.downcast::<gst_app::AppSink>().ok())
        .ok_or_else(|| VideoError::AppSink(name.to_owned()))
}

fn validate_media_url(source: &str) -> Result<Url, &'static str> {
    // url::Url accepts some surrounding ASCII whitespace; rejecting it here
    // keeps the accepted address identical to the value retained in `source`.
    if source.is_empty() || source.trim() != source {
        return Err(INVALID_MEDIA_URL);
    }

    let uri = Url::parse(source).map_err(|_| INVALID_MEDIA_URL)?;

    if uri.scheme() != "https"
        || !uri.username().is_empty()
        || uri.password().is_some()
        || uri.port().is_some_and(|port| port != 443)
        || uri.fragment().is_some()
    {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    let host = uri.host_str().ok_or(INVALID_MEDIA_URL)?;
    if !host.ends_with(".cdn.ruv.is") && !MEDIA_HOSTS.contains(&host) {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    if !uri.path().to_ascii_lowercase().ends_with(".m3u8") {
        return Err(UNSUPPORTED_MEDIA_SOURCE);
    }

    Ok(uri)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_only_https_ruv_hls_sources() {
        assert!(
            validate_media_url("https://ruvlive.akamaized.net/out/v1/channel/index.m3u8").is_ok()
        );
        assert!(validate_media_url("https://switcher.cdn.ruv.is/resolve/ruv/index.m3u8").is_ok());
        assert!(
            validate_media_url("https://ed4.cdn.ruv.is/token/live/d0/ruv/channel/index.m3u8")
                .is_ok()
        );

        for source in [
            "http://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
            "https://example.com/out/v1/channel/index.m3u8",
            "https://user@ruvlive.akamaized.net/out/v1/channel/index.m3u8",
            "https://ruvlive.akamaized.net/out/v1/channel/index.mp4",
            " https://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
        ] {
            assert!(validate_media_url(source).is_err(), "{source}");
        }
    }

    #[test]
    fn media_policy_rejects_deceptive_suffixes_userinfo_ips_ports_and_fragments() {
        // Host comparison happens on the parser-normalized (lowercased) host,
        // and the explicit default port is equivalent to no port at all.
        assert!(
            validate_media_url("https://RUVLIVE.AKAMAIZED.NET/out/v1/channel/index.m3u8").is_ok()
        );
        assert!(validate_media_url("https://ed4.cdn.ruv.is/token/live/INDEX.M3U8").is_ok());
        assert!(validate_media_url("https://ed4.cdn.ruv.is:443/token/live/index.m3u8").is_ok());

        for source in [
            // Deceptive suffixes around the one trusted wildcard.
            "https://evil-cdn.ruv.is.attacker.com/live/index.m3u8",
            "https://cdn.ruv.is.evil.com/live/index.m3u8",
            "https://CDN.RUV.IS.EVIL.COM/live/index.m3u8",
            "https://notcdn.ruv.is/live/index.m3u8",
            "https://ruvlive.akamaized.net.evil.com/out/index.m3u8",
            // Bare apex hosts: only subdomains of .cdn.ruv.is are trusted.
            "https://cdn.ruv.is/live/index.m3u8",
            "https://ruv.is/live/index.m3u8",
            // Userinfo, foreign port, IP literals, fragment, path tricks.
            "https://user:leyndarmal@ed4.cdn.ruv.is/live/index.m3u8",
            "https://ed4.cdn.ruv.is:8443/live/index.m3u8",
            "https://151.101.1.1/live/index.m3u8",
            "https://[2606:2e00::1]/live/index.m3u8",
            "https://ed4.cdn.ruv.is/live/index.m3u8#fragment",
            "https://ed4.cdn.ruv.is/live/playlist.m3u8.mp4",
        ] {
            assert!(validate_media_url(source).is_err(), "{source}");
        }
    }

    #[test]
    fn volume_and_mute_state_are_bounded_without_a_loaded_stream() {
        let mut player = PlayerState::default();
        player.set_volume(2.0);
        assert_eq!(player.volume, 1.0);
        player.set_volume(-1.0);
        assert_eq!(player.volume, 0.0);
        player.set_volume(f64::NAN);
        assert_eq!(player.volume, 0.0);

        assert!(!player.muted);
        player.toggle_mute();
        assert!(player.muted);
        player.toggle_mute();
        assert!(!player.muted);
    }

    #[test]
    fn seeking_without_media_is_a_safe_no_op() {
        let mut player = PlayerState::default();
        player.seek_to(120.0);
        player.skip_by(-10.0);
        assert_eq!(player.position_seconds(), 0.0);
        assert_eq!(player.duration_seconds(), 0.0);
        assert!(player.error.is_none());
    }

    #[test]
    fn infinite_volume_is_clamped_and_nonfinite_seeks_are_ignored() {
        let mut player = PlayerState::default();
        player.set_volume(f64::INFINITY);
        assert_eq!(player.volume, 1.0);
        player.set_volume(f64::NEG_INFINITY);
        assert_eq!(player.volume, 0.0);

        player.seek_to(f64::NAN);
        player.seek_to(f64::INFINITY);
        player.skip_by(f64::NAN);
        assert_eq!(player.position_seconds(), 0.0);
        assert!(player.error.is_none());
    }
}