Skip to main content

ssai_runtime/
playlist.rs

1//! Per-session HLS Interstitial playlist rendering.
2//!
3//! `EXT-X-DATERANGE CLASS="com.apple.hls.interstitial"` — Appendix D §D.2 of
4//! draft-pantos-hls-rfc8216bis, transcribed in full at
5//! `private/specs-verified/hls/ietf/rfc8216bis-interstitials.md` (§D.2, examples §D.6/§D.7). Renders
6//! over `broadcast-hls`: [`render_session_playlist`] clones the primary
7//! [`MediaPlaylist`] and appends this session's interstitial tag line to
8//! [`MediaPlaylist::extra_tags`] — the injection point `broadcast-hls`
9//! documents for exactly this purpose ("Extra tag lines emitted verbatim
10//! before segment entries (e.g. `#EXT-X-DATERANGE:...`)").
11//!
12//! Implements the attribute set issue #929 scoped: `X-ASSET-URI`/
13//! `X-ASSET-LIST`, `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`, `X-SNAP`,
14//! `X-RESTRICT`. `X-CONTENT-MAY-VARY`, `X-TIMELINE-OCCUPIES`,
15//! `X-TIMELINE-STYLE`, and the §D.3 skip-button-control attributes are not
16//! modeled.
17//!
18//! This crate does not do wall-clock math: [`InterstitialDateRange::start_date`]
19//! is a caller-supplied, already-formatted ISO-8601/RFC3339 string (the same
20//! convention `timed-metadata::daterange::DateRange` uses).
21
22use crate::decision::{AdBreakDecision, AssetSource, RestrictMode, SnapMode};
23use crate::error::{Error, Result};
24use alloc::format;
25use alloc::string::{String, ToString};
26use alloc::vec::Vec;
27use broadcast_hls::{AttrValue, MediaPlaylist, parse_attribute_list_strict, render_attribute_list};
28
29/// `CLASS` value for an Interstitial `EXT-X-DATERANGE` (Appendix D §D.2).
30pub const INTERSTITIAL_CLASS: &str = "com.apple.hls.interstitial";
31
32const TAG: &str = "#EXT-X-DATERANGE:";
33
34/// A rendered Interstitial `EXT-X-DATERANGE` tag: an [`AdBreakDecision`]
35/// plus the base-tag scheduling fields (`START-DATE`, `DURATION`, RFC 8216bis
36/// §4.4.5.1) the decision itself does not carry.
37#[derive(Debug, Clone, PartialEq)]
38pub struct InterstitialDateRange {
39    /// `ID` (quoted).
40    pub id: String,
41    /// `START-DATE` (quoted, ISO-8601/RFC3339) — caller-supplied; this
42    /// module does no wall-clock math.
43    pub start_date: String,
44    /// `DURATION` in seconds, if known.
45    pub duration: Option<f64>,
46    /// `X-ASSET-URI` or `X-ASSET-LIST`.
47    pub asset: AssetSource,
48    /// `X-RESUME-OFFSET` in seconds.
49    pub resume_offset: Option<f64>,
50    /// `X-PLAYOUT-LIMIT` in seconds.
51    pub playout_limit: Option<f64>,
52    /// `X-SNAP` identifiers.
53    pub snap: Vec<SnapMode>,
54    /// `X-RESTRICT` identifiers.
55    pub restrict: Vec<RestrictMode>,
56}
57
58impl InterstitialDateRange {
59    /// Build from an [`AdBreakDecision`] plus the two base-tag fields it
60    /// doesn't carry.
61    pub fn from_decision(
62        decision: &AdBreakDecision,
63        start_date: impl Into<String>,
64        duration: Option<f64>,
65    ) -> Self {
66        InterstitialDateRange {
67            id: decision.id.clone(),
68            start_date: start_date.into(),
69            duration,
70            asset: decision.asset.clone(),
71            resume_offset: decision.resume_offset,
72            playout_limit: decision.playout_limit,
73            snap: decision.snap.clone(),
74            restrict: decision.restrict.clone(),
75        }
76    }
77
78    /// Render one `#EXT-X-DATERANGE:` line. Attribute order is fixed (`ID`,
79    /// `CLASS`, `START-DATE`, `DURATION`, `X-ASSET-URI`/`X-ASSET-LIST`,
80    /// `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`, `X-SNAP`, `X-RESTRICT`) so
81    /// [`Self::parse_tag_line`] round-trips. Built solely from the typed
82    /// fields above — no stored source span is echoed.
83    ///
84    /// Every attribute value goes through [`AttrValue::quoted`]/[`bare`]
85    /// (issue #1140 / audit r14-SSAI-W1) before
86    /// [`broadcast_hls::render_attribute_list`] renders it: `id`,
87    /// `start_date` and the asset URI/list are frequently supplied (directly
88    /// or indirectly) by an [`crate::decision::AdDecisionProvider`] — a
89    /// third-party ad-decision service — so a `"`, CR or LF in any of them
90    /// is rejected here rather than terminating the attribute list and
91    /// injecting arbitrary tag lines into the session playlist. `duration`/
92    /// `playout_limit` must also be finite and non-negative and
93    /// `resume_offset` finite (it is a signed-decimal-floating-point, RFC 8216bis
94    /// Appendix D; audit r14-SSAI-W4) — `Error::InvalidDuration` otherwise.
95    ///
96    /// [`bare`]: AttrValue::bare
97    pub fn to_tag_line(&self) -> Result<String> {
98        let mut attrs = alloc::vec![
99            (String::from("ID"), AttrValue::quoted(self.id.clone())?),
100            (
101                String::from("CLASS"),
102                AttrValue::quoted(INTERSTITIAL_CLASS)?,
103            ),
104            (
105                String::from("START-DATE"),
106                AttrValue::quoted(self.start_date.clone())?,
107            ),
108        ];
109        if let Some(d) = self.duration {
110            attrs.push((
111                String::from("DURATION"),
112                AttrValue::bare(fmt_checked_secs("DURATION", d, Sign::NonNegative)?)?,
113            ));
114        }
115        match &self.asset {
116            AssetSource::Uri(uri) => {
117                attrs.push((String::from("X-ASSET-URI"), AttrValue::quoted(uri.clone())?));
118            }
119            AssetSource::List(uri) => {
120                attrs.push((
121                    String::from("X-ASSET-LIST"),
122                    AttrValue::quoted(uri.clone())?,
123                ));
124            }
125        }
126        if let Some(v) = self.resume_offset {
127            attrs.push((
128                String::from("X-RESUME-OFFSET"),
129                AttrValue::bare(fmt_checked_secs("X-RESUME-OFFSET", v, Sign::Signed)?)?,
130            ));
131        }
132        if let Some(v) = self.playout_limit {
133            attrs.push((
134                String::from("X-PLAYOUT-LIMIT"),
135                AttrValue::bare(fmt_checked_secs("X-PLAYOUT-LIMIT", v, Sign::NonNegative)?)?,
136            ));
137        }
138        if !self.snap.is_empty() {
139            for mode in &self.snap {
140                if let SnapMode::Other(t) = mode
141                    && is_unsafe_list_token(t)
142                {
143                    return Err(Error::TagParse("invalid X-SNAP token".to_string()));
144                }
145            }
146            let list = self
147                .snap
148                .iter()
149                .map(SnapMode::token)
150                .collect::<Vec<_>>()
151                .join(",");
152            attrs.push((String::from("X-SNAP"), AttrValue::quoted(list)?));
153        }
154        if !self.restrict.is_empty() {
155            for mode in &self.restrict {
156                if let RestrictMode::Other(t) = mode
157                    && is_unsafe_list_token(t)
158                {
159                    return Err(Error::TagParse("invalid X-RESTRICT token".to_string()));
160                }
161            }
162            let list = self
163                .restrict
164                .iter()
165                .map(RestrictMode::token)
166                .collect::<Vec<_>>()
167                .join(",");
168            attrs.push((String::from("X-RESTRICT"), AttrValue::quoted(list)?));
169        }
170        // `render_attribute_list` always prefixes each entry with `,`
171        // (broadcast-hls's convention for appending to an already-started
172        // attribute list); build the whole list through it and drop the
173        // single leading comma rather than special-casing the first entry.
174        let mut body = String::new();
175        render_attribute_list(&mut body, &attrs);
176        let mut out = String::from(TAG);
177        out.push_str(body.trim_start_matches(','));
178        Ok(out)
179    }
180
181    /// Parse one `#EXT-X-DATERANGE:` line with
182    /// `CLASS="com.apple.hls.interstitial"`. Errors with
183    /// [`Error::TagParse`] if the `CLASS` doesn't match (or is missing) or a
184    /// required attribute is absent, and [`Error::InvalidAssetSource`] if
185    /// neither or both of `X-ASSET-URI`/`X-ASSET-LIST` are present.
186    pub fn parse_tag_line(s: &str) -> Result<Self> {
187        let body = s
188            .strip_prefix(TAG)
189            .ok_or_else(|| Error::TagParse("missing #EXT-X-DATERANGE: prefix".to_string()))?;
190
191        // The shared workspace tokenizer (issue #1140 T12): `attrs` values
192        // already have their surrounding `"` stripped for quoted attributes.
193        let (attrs, quoted) = parse_attribute_list_strict(body)?;
194        for (name, must_be_quoted) in ATTRIBUTE_QUOTING {
195            if attrs.contains_key(*name) && quoted.contains(*name) != *must_be_quoted {
196                return Err(Error::TagParse(alloc::format!(
197                    "{name} must be {} (RFC 8216bis §4.2)",
198                    if *must_be_quoted {
199                        "a quoted-string"
200                    } else {
201                        "a decimal-floating-point, not quoted"
202                    }
203                )));
204            }
205        }
206
207        let id = attrs.get("ID").cloned();
208        let class_ok = attrs.get("CLASS").map(String::as_str) == Some(INTERSTITIAL_CLASS);
209        let start_date = attrs.get("START-DATE").cloned();
210        let duration = attrs
211            .get("DURATION")
212            .map(|v| parse_checked_secs("DURATION", v, Sign::NonNegative))
213            .transpose()?;
214        let uri = attrs.get("X-ASSET-URI").cloned();
215        let list = attrs.get("X-ASSET-LIST").cloned();
216        let resume_offset = attrs
217            .get("X-RESUME-OFFSET")
218            .map(|v| parse_checked_secs("X-RESUME-OFFSET", v, Sign::Signed))
219            .transpose()?;
220        let playout_limit = attrs
221            .get("X-PLAYOUT-LIMIT")
222            .map(|v| parse_checked_secs("X-PLAYOUT-LIMIT", v, Sign::NonNegative))
223            .transpose()?;
224        let snap = attrs
225            .get("X-SNAP")
226            .map(|v| parse_snap_list(v))
227            .unwrap_or_default();
228        let restrict = attrs
229            .get("X-RESTRICT")
230            .map(|v| parse_restrict_list(v))
231            .unwrap_or_default();
232
233        if !class_ok {
234            return Err(Error::TagParse(
235                "not an Interstitial EXT-X-DATERANGE (CLASS missing or mismatched)".to_string(),
236            ));
237        }
238        let asset = match (uri, list) {
239            (Some(u), None) => AssetSource::Uri(u),
240            (None, Some(l)) => AssetSource::List(l),
241            _ => return Err(Error::InvalidAssetSource),
242        };
243
244        Ok(InterstitialDateRange {
245            id: id.ok_or_else(|| Error::TagParse("missing ID".to_string()))?,
246            start_date: start_date
247                .ok_or_else(|| Error::TagParse("missing START-DATE".to_string()))?,
248            duration,
249            asset,
250            resume_offset,
251            playout_limit,
252            snap,
253            restrict,
254        })
255    }
256}
257
258/// `#EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.4.6) — the tag an
259/// `EXT-X-DATERANGE` playlist must contain at least one of (§4.4.5.1).
260const PDT_TAG: &str = "#EXT-X-PROGRAM-DATE-TIME:";
261
262/// Whether `base` carries an `EXT-X-PROGRAM-DATE-TIME` (a segment's
263/// `pre_tags`, where [`MediaPlaylist::parse`] files it, or the
264/// playlist-level `extra_tags`).
265fn has_program_date_time(base: &MediaPlaylist) -> bool {
266    base.extra_tags.iter().any(|t| t.starts_with(PDT_TAG))
267        || base.segments.iter().any(|s| {
268            s.program_date_time.is_some() || s.pre_tags.iter().any(|t| t.starts_with(PDT_TAG))
269        })
270}
271
272/// Clone `base` and append `active`'s rendered tag line (if any) to
273/// [`MediaPlaylist::extra_tags`] — the per-session playlist for one viewer.
274/// `base` is otherwise untouched: SSAI needs no per-viewer copy of the media
275/// itself (issue #929 design decision), only of this one tag line.
276///
277/// Fallible (issue #1140 / audit r14-SSAI-W1): an ad-decision-supplied value
278/// that fails [`InterstitialDateRange::to_tag_line`]'s validation is an
279/// error from this entry point, not a line silently dropped or injected.
280/// Also [`Error::MissingProgramDateTime`] when `active` is `Some` but `base`
281/// has no `EXT-X-PROGRAM-DATE-TIME` (RFC 8216bis §4.4.5.1; audit
282/// r14-SSAI-W3, issue #1125).
283///
284/// This deep-clones every segment per call; a per-viewer hot path should
285/// build one [`SessionPlaylistBase`] per base-playlist reload instead.
286pub fn render_session_playlist(
287    base: &MediaPlaylist,
288    active: Option<&InterstitialDateRange>,
289) -> Result<MediaPlaylist> {
290    let mut out = base.clone();
291    if let Some(dr) = active {
292        if !has_program_date_time(base) {
293            return Err(Error::MissingProgramDateTime);
294        }
295        out.extra_tags.push(dr.to_tag_line()?);
296    }
297    Ok(out)
298}
299
300/// A base playlist rendered **once**, so each viewer's per-session playlist
301/// costs one tag line plus a copy of the text — not a deep clone of every
302/// segment and a full re-serialisation per viewer per reload (audit
303/// r14-SSAI-O1, issue #1125).
304///
305/// The tag line is spliced in immediately before the first `#EXT-X-PART:` or
306/// `#EXTINF:` line, whichever comes first — i.e. before the first segment's
307/// part/`EXTINF` group, never between a segment's parts and its `EXTINF` (an
308/// `EXT-X-DATERANGE` is not positional within the playlist, RFC 8216bis
309/// §4.4.5.1) — or before `#EXT-X-ENDLIST` / at the end when the base has
310/// neither.
311#[derive(Debug, Clone)]
312pub struct SessionPlaylistBase {
313    head: String,
314    tail: String,
315    has_pdt: bool,
316}
317
318impl SessionPlaylistBase {
319    /// Render `base` once. Errors as [`MediaPlaylist::to_m3u8`].
320    pub fn new(base: &MediaPlaylist) -> Result<Self> {
321        let text = base.to_m3u8()?;
322        // `#EXT-X-PART:` (not `#EXT-X-PART-INF:`) or `#EXTINF:`, first of either.
323        let first_group = ["\n#EXT-X-PART:", "\n#EXTINF:"]
324            .iter()
325            .filter_map(|tag| text.find(tag))
326            .min();
327        let at = first_group
328            .or_else(|| text.rfind("\n#EXT-X-ENDLIST"))
329            .map_or(text.len(), |i| i + 1);
330        let (head, tail) = text.split_at(at);
331        Ok(SessionPlaylistBase {
332            head: head.to_string(),
333            tail: tail.to_string(),
334            has_pdt: has_program_date_time(base),
335        })
336    }
337
338    /// The viewer-independent playlist text (no break).
339    #[cfg(test)]
340    pub(crate) fn render_plain(&self) -> String {
341        let mut out = String::with_capacity(self.head.len() + self.tail.len());
342        out.push_str(&self.head);
343        out.push_str(&self.tail);
344        out
345    }
346
347    /// Append one viewer's playlist to `out`: the base text, with
348    /// `active`'s tag line spliced in. Errors as [`render_session_playlist`];
349    /// on error nothing is appended.
350    pub fn render_into(
351        &self,
352        out: &mut String,
353        active: Option<&InterstitialDateRange>,
354    ) -> Result<()> {
355        let line = match active {
356            Some(dr) => {
357                if !self.has_pdt {
358                    return Err(Error::MissingProgramDateTime);
359                }
360                Some(dr.to_tag_line()?)
361            }
362            None => None,
363        };
364        out.push_str(&self.head);
365        if let Some(line) = line {
366            out.push_str(&line);
367            out.push('\n');
368        }
369        out.push_str(&self.tail);
370        Ok(())
371    }
372}
373
374/// Format a non-negative, finite seconds value without a trailing `.0`,
375/// matching the spec examples (`X-RESUME-OFFSET=0`). Avoid `f64::fract()`
376/// (std-only intrinsic in `no_std`); use a cast comparison instead.
377///
378/// Rejects NaN/infinite/negative `v` (issue #1140 / audit r14-SSAI-W4):
379/// `NaN as i64 == 0` and `NaN != 0.0`, so an unchecked version of this
380/// function rendered `DURATION=NaN` — syntax the §4.2 grammar has no
381/// notation for.
382fn fmt_checked_secs(what: &'static str, v: f64, sign: Sign) -> Result<String> {
383    if !sign.admits(v) {
384        return Err(Error::InvalidDuration { what, value: v });
385    }
386    if v.abs() < EXACT_INT_LIMIT {
387        let trunc = v as i64;
388        if v == trunc as f64 {
389            return Ok(format!("{trunc}"));
390        }
391    }
392    Ok(format!("{v}"))
393}
394
395/// Integers up to 2^53 are exactly representable in an `f64`; beyond it the
396/// saturating `as i64` cast no longer round-trips.
397const EXACT_INT_LIMIT: f64 = 9_007_199_254_740_992.0;
398
399#[derive(Clone, Copy)]
400enum Sign {
401    NonNegative,
402    Signed,
403}
404
405impl Sign {
406    fn admits(self, v: f64) -> bool {
407        v.is_finite() && (matches!(self, Sign::Signed) || v >= 0.0)
408    }
409}
410
411/// Parse a decimal-floating-point seconds attribute, rejecting a
412/// NaN/infinite/negative value at parse time (issue #1140 / audit
413/// r14-SSAI-W4) rather than letting it reach [`fmt_checked_secs`] on the
414/// next render.
415fn parse_checked_secs(what: &'static str, v: &str, sign: Sign) -> Result<f64> {
416    let parsed: f64 = v
417        .parse()
418        .map_err(|_| Error::TagParse(format!("bad number: {v}")))?;
419    if !sign.admits(parsed) {
420        return Err(Error::InvalidDuration {
421            what,
422            value: parsed,
423        });
424    }
425    Ok(parsed)
426}
427
428fn is_unsafe_list_token(token: &str) -> bool {
429    token.contains(['"', ',', '\r', '\n'])
430}
431
432fn parse_snap_list(v: &str) -> Vec<SnapMode> {
433    v.split(',')
434        .map(str::trim)
435        .filter(|tok| !tok.is_empty())
436        .map(|tok| match tok {
437            "OUT" => SnapMode::Out,
438            "IN" => SnapMode::In,
439            other => SnapMode::Other(other.to_string()),
440        })
441        .collect()
442}
443
444fn parse_restrict_list(v: &str) -> Vec<RestrictMode> {
445    v.split(',')
446        .map(str::trim)
447        .filter(|tok| !tok.is_empty())
448        .map(|tok| match tok {
449            "SKIP" => RestrictMode::Skip,
450            "JUMP" => RestrictMode::Jump,
451            other => RestrictMode::Other(other.to_string()),
452        })
453        .collect()
454}
455
456/// Whether each interstitial attribute is a quoted-string (`true`) or an
457/// unquoted decimal-floating-point (`false`), RFC 8216bis §4.2 and Appendix D.
458const ATTRIBUTE_QUOTING: &[(&str, bool)] = &[
459    ("ID", true),
460    ("CLASS", true),
461    ("START-DATE", true),
462    ("X-ASSET-URI", true),
463    ("X-ASSET-LIST", true),
464    ("X-SNAP", true),
465    ("X-RESTRICT", true),
466    ("DURATION", false),
467    ("X-RESUME-OFFSET", false),
468    ("X-PLAYOUT-LIMIT", false),
469];
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use alloc::vec;
475    use broadcast_hls::{DecimalSeconds, MediaSegment};
476
477    fn sample() -> InterstitialDateRange {
478        InterstitialDateRange {
479            id: "ad1".to_string(),
480            start_date: "2020-01-02T21:55:44.000Z".to_string(),
481            duration: Some(15.0),
482            asset: AssetSource::Uri("http://example.com/ad1.m3u8".to_string()),
483            resume_offset: Some(0.0),
484            playout_limit: None,
485            snap: Vec::new(),
486            restrict: vec![RestrictMode::Skip, RestrictMode::Jump],
487        }
488    }
489
490    #[test]
491    fn tag_line_matches_the_spec_example_shape() {
492        // Appendix D §D.6 example, reproduced with this crate's own types.
493        let line = sample().to_tag_line().unwrap();
494        assert!(line.starts_with(TAG));
495        assert!(line.contains(r#"CLASS="com.apple.hls.interstitial""#));
496        assert!(line.contains(r#"X-ASSET-URI="http://example.com/ad1.m3u8""#));
497        assert!(line.contains("X-RESUME-OFFSET=0"));
498        assert!(line.contains(r#"X-RESTRICT="SKIP,JUMP""#));
499    }
500
501    #[test]
502    fn tag_line_round_trips() {
503        let dr = sample();
504        let line = dr.to_tag_line().unwrap();
505        let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
506        assert_eq!(back, dr);
507    }
508
509    #[test]
510    fn asset_list_variant_round_trips() {
511        let dr = InterstitialDateRange {
512            id: "ad2".to_string(),
513            start_date: "2020-01-02T21:55:44.000Z".to_string(),
514            duration: Some(30.0),
515            asset: AssetSource::List("http://example.com/adv.json".to_string()),
516            resume_offset: None,
517            playout_limit: Some(20.0),
518            snap: vec![SnapMode::Out, SnapMode::In],
519            restrict: Vec::new(),
520        };
521        let line = dr.to_tag_line().unwrap();
522        assert!(line.contains(r#"X-ASSET-LIST="http://example.com/adv.json""#));
523        assert!(line.contains(r#"X-SNAP="OUT,IN""#));
524        let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
525        assert_eq!(back, dr);
526    }
527
528    /// Mutating any field must change the rendered output — the anti-cheat
529    /// property a raw-passthrough (source-span-echoing) serializer cannot
530    /// satisfy, since an echo can't reflect a mutation it didn't store.
531    #[test]
532    fn mutating_a_field_changes_the_output() {
533        let base = sample();
534        let base_line = base.to_tag_line().unwrap();
535
536        let mut mutated = base.clone();
537        mutated.id = "different-id".to_string();
538        assert_ne!(mutated.to_tag_line().unwrap(), base_line);
539
540        let mut mutated = base.clone();
541        mutated.resume_offset = Some(5.0);
542        assert_ne!(mutated.to_tag_line().unwrap(), base_line);
543
544        let mut mutated = base.clone();
545        mutated.restrict = vec![RestrictMode::Skip];
546        assert_ne!(mutated.to_tag_line().unwrap(), base_line);
547
548        let mut mutated = base;
549        mutated.asset = AssetSource::Uri("http://example.com/different.m3u8".to_string());
550        assert_ne!(mutated.to_tag_line().unwrap(), base_line);
551    }
552
553    /// Audit r14-SSAI-W1: a `"`, CR or LF in an ad-decision-supplied value
554    /// (here `X-ASSET-URI`) must be rejected, not injected into the
555    /// playlist as a broken attribute list or an extra tag line.
556    #[test]
557    fn to_tag_line_rejects_injection_in_asset_uri() {
558        let mut dr = sample();
559        dr.asset = AssetSource::Uri("http://example.com/ad1.m3u8\"\r\n#EXT-X-ENDLIST".to_string());
560        assert!(matches!(
561            dr.to_tag_line().unwrap_err(),
562            Error::HlsAttrValue(_)
563        ));
564    }
565
566    #[test]
567    fn to_tag_line_rejects_injection_in_id() {
568        let mut dr = sample();
569        dr.id = "ad\"1".to_string();
570        assert!(matches!(
571            dr.to_tag_line().unwrap_err(),
572            Error::HlsAttrValue(_)
573        ));
574    }
575
576    /// Audit r14-SSAI-W4: NaN/infinite/negative durations must be rejected,
577    /// never rendered as the bare token `NaN`/`inf`/a negative number.
578    #[test]
579    fn to_tag_line_rejects_non_finite_and_negative_durations() {
580        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
581            let mut dr = sample();
582            dr.duration = Some(bad);
583            let err = dr.to_tag_line().unwrap_err();
584            assert!(
585                matches!(err, Error::InvalidDuration { .. }),
586                "duration {bad} should be rejected, got {err:?}"
587            );
588
589            if bad != -1.0 {
590                let mut dr = sample();
591                dr.resume_offset = Some(bad);
592                assert!(matches!(
593                    dr.to_tag_line().unwrap_err(),
594                    Error::InvalidDuration { .. }
595                ));
596            }
597
598            let mut dr = sample();
599            dr.playout_limit = Some(bad);
600            assert!(matches!(
601                dr.to_tag_line().unwrap_err(),
602                Error::InvalidDuration { .. }
603            ));
604        }
605    }
606
607    #[test]
608    fn to_tag_line_rejects_unsafe_other_list_tokens() {
609        for bad in ["a\"b", "a,b", "a\rb", "a\nb"] {
610            let mut dr = sample();
611            dr.snap = vec![SnapMode::Other(bad.to_string())];
612            assert!(
613                matches!(dr.to_tag_line().unwrap_err(), Error::TagParse(_)),
614                "snap {bad:?}"
615            );
616            let mut dr = sample();
617            dr.restrict = vec![RestrictMode::Other(bad.to_string())];
618            assert!(
619                matches!(dr.to_tag_line().unwrap_err(), Error::TagParse(_)),
620                "restrict {bad:?}"
621            );
622        }
623        let mut dr = sample();
624        dr.snap = vec![SnapMode::Other("X-CUSTOM".to_string())];
625        assert!(dr.to_tag_line().is_ok());
626    }
627
628    #[test]
629    fn snap_and_restrict_lists_trim_tokens_and_drop_empty_ones() {
630        let line = sample().to_tag_line().unwrap();
631        let with = |attr: &str| {
632            let base = line.replace(",X-RESTRICT=\"SKIP,JUMP\"", "");
633            format!("{base},{attr}")
634        };
635        let dr = InterstitialDateRange::parse_tag_line(&with("X-SNAP=\" OUT , IN \"")).unwrap();
636        assert_eq!(dr.snap, vec![SnapMode::Out, SnapMode::In]);
637        let dr = InterstitialDateRange::parse_tag_line(&with("X-SNAP=\"OUT,\"")).unwrap();
638        assert_eq!(dr.snap, vec![SnapMode::Out]);
639        let dr =
640            InterstitialDateRange::parse_tag_line(&with("X-RESTRICT=\" SKIP , JUMP \"")).unwrap();
641        assert_eq!(dr.restrict, vec![RestrictMode::Skip, RestrictMode::Jump]);
642        let dr = InterstitialDateRange::parse_tag_line(&with("X-RESTRICT=\"SKIP,\"")).unwrap();
643        assert_eq!(dr.restrict, vec![RestrictMode::Skip]);
644    }
645
646    #[test]
647    fn negative_resume_offset_round_trips() {
648        let mut dr = sample();
649        dr.resume_offset = Some(-5.0);
650        let line = dr.to_tag_line().unwrap();
651        assert!(line.contains("X-RESUME-OFFSET=-5"), "{line}");
652        let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
653        assert_eq!(back.resume_offset, Some(-5.0));
654    }
655
656    #[test]
657    fn parse_accepts_negative_resume_offset() {
658        let s = "#EXT-X-DATERANGE:ID=\"a\",CLASS=\"com.apple.hls.interstitial\",\
659                 START-DATE=\"2020-01-02T21:55:44.000Z\",X-ASSET-URI=\"http://x/a\",\
660                 X-RESUME-OFFSET=-3";
661        let parsed = InterstitialDateRange::parse_tag_line(s).unwrap();
662        assert_eq!(parsed.resume_offset, Some(-3.0));
663    }
664
665    #[test]
666    fn negative_playout_limit_is_still_rejected() {
667        let mut dr = sample();
668        dr.playout_limit = Some(-1.0);
669        assert!(matches!(
670            dr.to_tag_line().unwrap_err(),
671            Error::InvalidDuration { .. }
672        ));
673    }
674
675    #[test]
676    fn duration_beyond_exact_integer_range_renders_exactly() {
677        let mut dr = sample();
678        dr.duration = Some(9_223_372_036_854_775_808.0);
679        let line = dr.to_tag_line().unwrap();
680        assert!(!line.contains("DURATION=9223372036854775807"), "{line}");
681        let back = InterstitialDateRange::parse_tag_line(&line).unwrap();
682        assert_eq!(back.duration, Some(9_223_372_036_854_775_808.0));
683    }
684
685    #[test]
686    fn parse_rejects_non_finite_duration() {
687        let line = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
688                     START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
689                     DURATION=nan";
690        assert!(matches!(
691            InterstitialDateRange::parse_tag_line(line).unwrap_err(),
692            Error::InvalidDuration { .. }
693        ));
694    }
695
696    #[test]
697    fn parse_rejects_wrong_or_missing_class() {
698        let line = "#EXT-X-DATERANGE:ID=\"x\",START-DATE=\"2020-01-01T00:00:00Z\",\
699                     X-ASSET-URI=\"http://x/a.m3u8\"";
700        let err = InterstitialDateRange::parse_tag_line(line).unwrap_err();
701        assert!(matches!(err, Error::TagParse(_)));
702    }
703
704    #[test]
705    fn parse_rejects_both_or_neither_asset_source() {
706        let both = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
707                     START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\",\
708                     X-ASSET-LIST=\"http://x/list.json\"";
709        assert!(matches!(
710            InterstitialDateRange::parse_tag_line(both).unwrap_err(),
711            Error::InvalidAssetSource
712        ));
713
714        let neither = "#EXT-X-DATERANGE:ID=\"x\",CLASS=\"com.apple.hls.interstitial\",\
715                        START-DATE=\"2020-01-01T00:00:00Z\"";
716        assert!(matches!(
717            InterstitialDateRange::parse_tag_line(neither).unwrap_err(),
718            Error::InvalidAssetSource
719        ));
720    }
721
722    #[test]
723    fn render_session_playlist_appends_only_for_the_active_session() {
724        let mut base = MediaPlaylist {
725            target_duration: 6,
726            ..Default::default()
727        };
728        base.segments.push(MediaSegment {
729            duration: DecimalSeconds::new(6.0).unwrap(),
730            uri: "main.ts".to_string(),
731            ..Default::default()
732        });
733
734        base.segments[0].pre_tags = vec![PDT_LINE.to_string()];
735
736        let dr = sample();
737        let with_break = render_session_playlist(&base, Some(&dr)).unwrap();
738        let without_break = render_session_playlist(&base, None).unwrap();
739
740        assert!(with_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
741        assert!(!without_break.to_m3u8().unwrap().contains("X-ASSET-URI"));
742        // The base playlist itself (what every other viewer renders from)
743        // is untouched.
744        assert!(base.extra_tags.is_empty());
745        // Only the tag line differs; the segment list is byte-identical.
746        assert_eq!(with_break.segments, without_break.segments);
747    }
748
749    const PDT_LINE: &str = "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z";
750
751    fn base_with_segments(n: usize, pdt: bool) -> MediaPlaylist {
752        let mut base = MediaPlaylist {
753            target_duration: 6,
754            ..Default::default()
755        };
756        for i in 0..n {
757            base.segments.push(MediaSegment {
758                duration: DecimalSeconds::new(6.0).unwrap(),
759                uri: format!("main{i}.ts"),
760                pre_tags: if pdt && i == 0 {
761                    vec![PDT_LINE.to_string()]
762                } else {
763                    Vec::new()
764                },
765                ..Default::default()
766            });
767        }
768        base
769    }
770
771    /// Audit r14-SSAI-W3: a DATERANGE needs a PDT in the playlist
772    /// (RFC 8216bis §4.4.5.1); both render paths refuse without one, and
773    /// accept the PDT wherever the base carries it.
774    #[test]
775    fn daterange_without_pdt_is_an_error_on_both_paths() {
776        let dr = sample();
777        let no_pdt = base_with_segments(2, false);
778        assert!(matches!(
779            render_session_playlist(&no_pdt, Some(&dr)).unwrap_err(),
780            Error::MissingProgramDateTime
781        ));
782        let b = SessionPlaylistBase::new(&no_pdt).unwrap();
783        let mut out = String::new();
784        assert!(matches!(
785            b.render_into(&mut out, Some(&dr)).unwrap_err(),
786            Error::MissingProgramDateTime
787        ));
788        assert!(out.is_empty(), "nothing appended on error");
789        // No break requested: no DATERANGE emitted, so no PDT needed.
790        assert!(render_session_playlist(&no_pdt, None).is_ok());
791        b.render_into(&mut out, None).unwrap();
792        assert_eq!(out, no_pdt.to_m3u8().unwrap());
793
794        // PDT as a playlist-level extra tag also satisfies the rule.
795        let mut extra = base_with_segments(1, false);
796        extra.extra_tags.push(PDT_LINE.to_string());
797        assert!(render_session_playlist(&extra, Some(&dr)).is_ok());
798        assert!(
799            SessionPlaylistBase::new(&extra)
800                .unwrap()
801                .render_into(&mut String::new(), Some(&dr))
802                .is_ok()
803        );
804    }
805
806    /// The spliced render must contain exactly the base text plus one tag
807    /// line, placed before the first EXTINF, and the result must reparse.
808    #[test]
809    fn session_playlist_base_splices_one_line_before_the_first_extinf() {
810        let base = base_with_segments(3, true);
811        let dr = sample();
812        let plain = base.to_m3u8().unwrap();
813        let b = SessionPlaylistBase::new(&base).unwrap();
814        assert_eq!(b.render_plain(), plain);
815
816        let mut out = String::new();
817        b.render_into(&mut out, Some(&dr)).unwrap();
818        let line = dr.to_tag_line().unwrap();
819        let expected = plain.replacen("#EXTINF:", &format!("{line}\n#EXTINF:"), 1);
820        assert_eq!(out, expected);
821        assert_eq!(out.lines().filter(|l| l.starts_with(TAG)).count(), 1);
822        let reparsed = MediaPlaylist::parse(&out).unwrap();
823        assert_eq!(reparsed.segments.len(), 3);
824
825        // An injection attempt errors and appends nothing.
826        let mut bad = sample();
827        bad.asset = AssetSource::Uri("x\"\n#EXT-X-ENDLIST".to_string());
828        let mut out2 = String::from("keep");
829        assert!(b.render_into(&mut out2, Some(&bad)).is_err());
830        assert_eq!(out2, "keep");
831    }
832
833    /// A segment-less base with `#EXT-X-ENDLIST` must get the tag before it.
834    #[test]
835    fn session_playlist_base_without_segments_inserts_before_endlist() {
836        let mut base = MediaPlaylist {
837            target_duration: 6,
838            endlist: true,
839            ..Default::default()
840        };
841        base.extra_tags.push(PDT_LINE.to_string());
842        let b = SessionPlaylistBase::new(&base).unwrap();
843        let mut out = String::new();
844        b.render_into(&mut out, Some(&sample())).unwrap();
845        let lines: Vec<&str> = out.lines().collect();
846        let n = lines.len();
847        assert_eq!(lines[n - 1], "#EXT-X-ENDLIST");
848        assert!(lines[n - 2].starts_with(TAG));
849    }
850
851    /// An LL-HLS base: the first segment's `EXT-X-PART` lines precede its
852    /// `EXTINF`, so the DATERANGE must land before the first PART, not between
853    /// a segment's parts and its `EXTINF`.
854    #[test]
855    fn low_latency_base_gets_the_tag_before_the_first_part() {
856        use broadcast_hls::{LowLatencyConfig, PartSpec};
857        let mut base = base_with_segments(2, true);
858        for seg in &mut base.segments {
859            seg.parts = vec![
860                PartSpec {
861                    uri: format!("{}.0.m4s", seg.uri),
862                    duration: DecimalSeconds::new(3.0).unwrap(),
863                    ..Default::default()
864                },
865                PartSpec {
866                    uri: format!("{}.1.m4s", seg.uri),
867                    duration: DecimalSeconds::new(3.0).unwrap(),
868                    ..Default::default()
869                },
870            ];
871        }
872        base.low_latency = Some(LowLatencyConfig {
873            part_target: Some(DecimalSeconds::new(3.0).unwrap()),
874            ..Default::default()
875        });
876        let plain = base.to_m3u8().unwrap();
877        let first_part = plain.find("#EXT-X-PART:").expect("the base has parts");
878        let first_extinf = plain.find("#EXTINF:").unwrap();
879        assert!(
880            first_part < first_extinf,
881            "premise: parts precede EXTINF\n{plain}"
882        );
883
884        let mut out = String::new();
885        SessionPlaylistBase::new(&base)
886            .unwrap()
887            .render_into(&mut out, Some(&sample()))
888            .unwrap();
889        let tag = out.find(TAG).expect("tag present");
890        let part = out.find("#EXT-X-PART:").unwrap();
891        let extinf = out.find("#EXTINF:").unwrap();
892        assert!(tag < part && part < extinf, "{out}");
893        assert_eq!(out.matches(TAG).count(), 1);
894        // The PART-INF header line is not mistaken for a part.
895        assert!(out.find("#EXT-X-PART-INF").is_none_or(|i| i < tag), "{out}");
896        MediaPlaylist::parse(&out).expect("the spliced LL playlist parses");
897    }
898
899    #[test]
900    fn parse_tag_line_rejects_malformed_attribute_list_unterminated_quote() {
901        let line = "#EXT-X-DATERANGE:ID=\"x";
902        assert!(matches!(
903            InterstitialDateRange::parse_tag_line(line).unwrap_err(),
904            Error::HlsAttrValue(broadcast_hls::Error::UnterminatedQuotedString { .. })
905        ));
906    }
907
908    #[test]
909    fn parse_tag_line_rejects_malformed_attribute_list_duplicate_key() {
910        let line = "#EXT-X-DATERANGE:ID=\"x\",ID=\"y\",CLASS=\"com.apple.hls.interstitial\",START-DATE=\"2020-01-01T00:00:00Z\",X-ASSET-URI=\"http://x/a.m3u8\"";
911        assert!(matches!(
912            InterstitialDateRange::parse_tag_line(line).unwrap_err(),
913            Error::HlsAttrValue(broadcast_hls::Error::DuplicateAttribute { name }) if name == "ID"
914        ));
915    }
916
917    const LINE_PREFIX: &str = "#EXT-X-DATERANGE:CLASS=\"com.apple.hls.interstitial\",START-DATE=\"2020-01-02T21:55:44.000Z\",X-ASSET-URI=\"https://x/a.m3u8\"";
918
919    #[test]
920    fn parse_rejects_an_unquoted_id_and_a_quoted_duration() {
921        for extra in [",ID=ad1", ",ID=\"ad1\",DURATION=\"15\""] {
922            let line = alloc::format!("{LINE_PREFIX}{extra}");
923            assert!(
924                matches!(
925                    InterstitialDateRange::parse_tag_line(&line),
926                    Err(Error::TagParse(_))
927                ),
928                "{line}"
929            );
930        }
931        let ok = alloc::format!("{LINE_PREFIX},ID=\"ad1\",DURATION=15");
932        assert!(InterstitialDateRange::parse_tag_line(&ok).is_ok());
933    }
934
935    #[test]
936    fn unknown_snap_and_restrict_identifiers_round_trip_byte_identical() {
937        let line = alloc::format!(
938            "{LINE_PREFIX},ID=\"ad1\",X-SNAP=\"OUT,SIDEWAYS\",X-RESTRICT=\"SKIP,TELEPORT\""
939        );
940        let parsed = InterstitialDateRange::parse_tag_line(&line).unwrap();
941        assert_eq!(parsed.snap[1], SnapMode::Other("SIDEWAYS".to_string()));
942        let again = parsed.to_tag_line().unwrap();
943        let reparsed = InterstitialDateRange::parse_tag_line(&again).unwrap();
944        assert_eq!(reparsed, parsed);
945        assert!(again.contains("X-SNAP=\"OUT,SIDEWAYS\""), "{again}");
946        assert!(again.contains("X-RESTRICT=\"SKIP,TELEPORT\""), "{again}");
947    }
948}