From bc1c50c156a2c260dff71ba03471c5ef6923d62a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 2 May 2026 13:28:42 -0700 Subject: [PATCH 1/2] hang: catalog renditions can reference tracks in another broadcast Add an optional `broadcast` field on video/audio renditions that points at the broadcast publishing the track, expressed as a relative path ("../source", etc.). Lets a worker author a downstream catalog that sidecars unchanged renditions against an upstream broadcast without republishing the bytes. Watchers resolve the field against the catalog broadcast's path and subscribe on the same connection. - moq-lite: new `PathRelative` type and `Path::resolve` for `..`-aware resolution, with full unit coverage. - @moq/hang: mirror `resolveBroadcast` helper for the browser side. - @moq/watch: `Broadcast.trackBroadcast` looks up the override broadcast with lifetime tied to the calling Effect; audio/video decoder + MSE backends use it. - rs/hang/examples/subscribe.rs: demonstrate consumer-side resolution via `OriginConsumer::announced_broadcast`. Co-Authored-By: Claude Opus 4.7 (1M context) --- js/hang/src/catalog/audio.ts | 5 + js/hang/src/catalog/index.ts | 1 + js/hang/src/catalog/path.ts | 33 +++++ js/hang/src/catalog/video.ts | 5 + js/watch/src/audio/decoder.ts | 4 +- js/watch/src/audio/mse.ts | 8 +- js/watch/src/broadcast.ts | 24 ++++ js/watch/src/video/decoder.ts | 4 +- js/watch/src/video/mse.ts | 8 +- rs/hang/examples/subscribe.rs | 17 ++- rs/hang/examples/video.rs | 1 + rs/hang/src/catalog/audio/mod.rs | 6 + rs/hang/src/catalog/root.rs | 36 +++++ rs/hang/src/catalog/video/mod.rs | 9 ++ rs/moq-lite/src/path.rs | 224 +++++++++++++++++++++++++++++++ rs/moq-mux/src/import/aac.rs | 1 + rs/moq-mux/src/import/av01.rs | 3 + rs/moq-mux/src/import/avc1.rs | 1 + rs/moq-mux/src/import/avc3.rs | 1 + rs/moq-mux/src/import/fmp4.rs | 7 + rs/moq-mux/src/import/hev1.rs | 1 + rs/moq-mux/src/import/opus.rs | 1 + rs/moq-mux/src/msf.rs | 4 + 23 files changed, 395 insertions(+), 9 deletions(-) create mode 100644 js/hang/src/catalog/path.ts diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 47178addc..f9a02f02f 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -10,6 +10,11 @@ const TrackSchema = z.object({ // Mirrors AudioDecoderConfig // https://w3c.github.io/webcodecs/#audio-decoder-config export const AudioConfigSchema = z.object({ + // Optional reference to another broadcast that publishes this track, expressed + // relative to the broadcast that served this catalog (e.g. "../source"). + // If unset, the track lives in the same broadcast as the catalog. + broadcast: z.optional(z.string()), + // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/hang/src/catalog/index.ts b/js/hang/src/catalog/index.ts index ecf4e9daf..3e27559f5 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -4,6 +4,7 @@ export * from "./chat"; export * from "./container"; export * from "./integers"; export * from "./location"; +export * from "./path"; export * from "./preview"; export * from "./priority"; export * from "./root"; diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts new file mode 100644 index 000000000..c7112f34e --- /dev/null +++ b/js/hang/src/catalog/path.ts @@ -0,0 +1,33 @@ +import { Path } from "@moq/lite"; + +/** + * Resolve a relative broadcast reference against the path of the broadcast that served the catalog. + * + * `..` segments pop the last segment of the base path; named segments are appended. + * Excess `..` clamps at the root, returning an empty path. An empty `rel` returns the base + * path unchanged. + * + * Mirrors the Rust `Path::resolve(&PathRelative)` helper used by hang catalogs to express + * cross-broadcast track references. + * + * @example + * ```typescript + * resolveBroadcast(Path.from("a/b/c"), "../source"); // "a/b/source" + * resolveBroadcast(Path.from("a/b"), "x/y"); // "a/b/x/y" + * resolveBroadcast(Path.from("a"), "../../x"); // "x" + * ``` + */ +export function resolveBroadcast(base: Path.Valid, rel: string): Path.Valid { + const baseSegments = base === "" ? [] : base.split("/"); + const relSegments = rel.split("/").filter((s) => s !== ""); + + for (const seg of relSegments) { + if (seg === "..") { + baseSegments.pop(); + } else { + baseSegments.push(seg); + } + } + + return Path.from(...baseSegments); +} diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index dffb5e7cb..0c746229d 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -9,6 +9,11 @@ const TrackSchema = z.object({ // Based on VideoDecoderConfig export const VideoConfigSchema = z.object({ + // Optional reference to another broadcast that publishes this track, expressed + // relative to the broadcast that served this catalog (e.g. "../source"). + // If unset, the track lives in the same broadcast as the catalog. + broadcast: z.optional(z.string()), + // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index 2facbfafa..9dbaeeb24 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -174,7 +174,9 @@ export class Decoder { const config = effect.get(this.source.config); if (!config) return; - const active = effect.get(broadcast.active); + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); if (!active) return; const sub = active.subscribe(track, Catalog.PRIORITY.audio); diff --git a/js/watch/src/audio/mse.ts b/js/watch/src/audio/mse.ts index ab3ce0942..9f6b14948 100644 --- a/js/watch/src/audio/mse.ts +++ b/js/watch/src/audio/mse.ts @@ -52,15 +52,17 @@ export class Mse implements Backend { const broadcast = effect.get(this.source.broadcast); if (!broadcast) return; - const active = effect.get(broadcast.active); - if (!active) return; - const track = effect.get(this.source.track); if (!track) return; const config = effect.get(this.source.config); if (!config) return; + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); + if (!active) return; + const mime = `audio/mp4; codecs="${config.codec}"`; const sourceBuffer = mediaSource.addSourceBuffer(mime); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 05dc97030..d286f8586 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -159,6 +159,30 @@ export class Broadcast { }); } + /** + * Resolve the `Moq.Broadcast` that publishes a given track. + * + * If `configBroadcast` is set, treat it as a path relative to this broadcast's name and + * subscribe to the resolved broadcast on the same connection. Otherwise return the catalog's + * own active broadcast. + * + * The lifetime of any newly-opened broadcast is tied to the provided `Effect`. + */ + trackBroadcast(effect: Effect, configBroadcast: string | undefined): Moq.Broadcast | undefined { + const active = effect.get(this.active); + if (!active) return undefined; + if (!configBroadcast) return active; + + const conn = effect.get(this.connection); + if (!conn) return undefined; + + const basePath = effect.get(this.name); + const resolved = Catalog.resolveBroadcast(basePath, configBroadcast); + const remote = conn.consume(resolved); + effect.cleanup(() => remote.close()); + return remote; + } + close() { this.signals.close(); } diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index fed21f65c..15f6d8dfb 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -74,7 +74,9 @@ export class Decoder implements Backend { } const [_, source, track, config] = values; - const broadcast = effect.get(source.active); + // Honor a per-rendition `broadcast` override: subscribe on the resolved source + // broadcast instead of the catalog's broadcast. Falls back to the catalog's broadcast. + const broadcast = source.trackBroadcast(effect, config.broadcast); if (!broadcast) return; // Start a new pending effect. diff --git a/js/watch/src/video/mse.ts b/js/watch/src/video/mse.ts index cf5af6abe..2f160cf99 100644 --- a/js/watch/src/video/mse.ts +++ b/js/watch/src/video/mse.ts @@ -50,15 +50,17 @@ export class Mse implements Backend { const broadcast = effect.get(this.source.broadcast); if (!broadcast) return; - const active = effect.get(broadcast.active); - if (!active) return; - const track = effect.get(this.source.track); if (!track) return; const config = effect.get(this.source.config); if (!config) return; + // Honor a per-rendition `broadcast` override: resolve to the source broadcast if set, + // otherwise use the catalog's own broadcast. + const active = broadcast.trackBroadcast(effect, config.broadcast); + if (!active) return; + const mime = `video/mp4; codecs="${config.codec}"`; const sourceBuffer = mediaSource.addSourceBuffer(mime); diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index a365e089f..a7cf8c736 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -68,13 +68,28 @@ async fn run_subscribe(mut consumer: moq_lite::OriginConsumer) -> anyhow::Result codec = %config.codec, width = ?config.coded_width, height = ?config.coded_height, + broadcast_override = ?config.broadcast.as_ref().map(|p| p.as_str()), "subscribing to video track" ); + // If the rendition references a different broadcast (e.g. a source feed that this + // catalog only sidecars), resolve it relative to the catalog's broadcast path and + // wait for the announcement. Otherwise subscribe on the catalog's broadcast. + let track_broadcast = match config.broadcast.as_ref() { + Some(rel) => { + let resolved = path.resolve(rel); + consumer + .announced_broadcast(&resolved) + .await + .ok_or_else(|| anyhow::anyhow!("source broadcast unavailable: {resolved}"))? + } + None => broadcast.clone(), + }; + // Subscribe to the video track. let track = moq_lite::Track::new(name.clone()); - let track_consumer = broadcast.subscribe_track( + let track_consumer = track_broadcast.subscribe_track( &track, moq_lite::Subscription { priority: 1, diff --git a/rs/hang/examples/video.rs b/rs/hang/examples/video.rs index bfdf2595f..625a4f867 100644 --- a/rs/hang/examples/video.rs +++ b/rs/hang/examples/video.rs @@ -46,6 +46,7 @@ fn create_track(broadcast: &mut moq_lite::BroadcastProducer) -> anyhow::Result, + // The codec, see the registry for details: // https://w3c.github.io/webcodecs/codec_registry.html #[serde_as(as = "DisplayFromStr")] diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 748795bc9..a1a3cb3f8 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -136,6 +136,7 @@ mod test { video_renditions.insert( "video".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -160,6 +161,7 @@ mod test { audio_renditions.insert( "audio".to_string(), AudioConfig { + broadcast: None, codec: Opus, sample_rate: 48_000, channel_count: 2, @@ -189,4 +191,38 @@ mod test { let output = decoded.to_string().expect("failed to encode"); assert_eq!(encoded, output, "wrong encoded output"); } + + #[test] + fn rendition_with_broadcast_override() { + // Decode a catalog where one rendition references a track in a sibling broadcast, + // and verify the `broadcast` field round-trips through serde. + let encoded = r#"{ + "video": { + "renditions": { + "video": { + "broadcast": "../source", + "codec": "avc1.64001f", + "codedWidth": 1280, + "codedHeight": 720, + "container": {"kind": "legacy"} + } + } + } + }"#; + + let parsed = Catalog::from_str(encoded).expect("failed to decode"); + let rendition = parsed.video.renditions.get("video").expect("missing rendition"); + assert_eq!( + rendition.broadcast.as_ref().map(|p| p.as_str()), + Some("../source"), + "broadcast field did not deserialize" + ); + + // Re-encoding preserves the broadcast field. + let output = parsed.to_string().expect("failed to encode"); + assert!( + output.contains(r#""broadcast":"../source""#), + "broadcast field missing from re-encoded JSON: {output}" + ); + } } diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index d972ac2a9..bcfc51c17 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -107,6 +107,15 @@ pub struct Display { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct VideoConfig { + /// Optional reference to another broadcast that publishes this track, expressed + /// relative to the broadcast that served this catalog. If unset, the track lives + /// in the same broadcast as the catalog. + /// + /// This allows a worker to author a downstream catalog that points unchanged + /// renditions at the source broadcast without re-publishing the bytes. + #[serde(default)] + pub broadcast: Option, + /// The codec, see the registry for details: /// #[serde_as(as = "DisplayFromStr")] diff --git a/rs/moq-lite/src/path.rs b/rs/moq-lite/src/path.rs index 0321897ca..ba1d4c2d6 100644 --- a/rs/moq-lite/src/path.rs +++ b/rs/moq-lite/src/path.rs @@ -232,6 +232,39 @@ impl<'a> Path<'a> { Path(Cow::Owned(format!("{}/{}", self.0, other.as_str()))) } } + + /// Resolve a [`PathRelative`] against this path. + /// + /// `..` segments in `rel` pop the last segment of the base; named segments are appended. + /// Excess `..` clamps at the root, returning an empty path. An empty `rel` returns this + /// path unchanged (as an owned copy). + /// + /// # Examples + /// ``` + /// use moq_lite::{Path, PathRelative}; + /// + /// let base = Path::new("a/b/c"); + /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); + /// assert_eq!(base.resolve(&PathRelative::new("d")).as_str(), "a/b/c/d"); + /// assert_eq!(base.resolve(&PathRelative::new("../../../../x")).as_str(), "x"); + /// ``` + pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { + let mut segments: Vec<&str> = if self.0.is_empty() { + Vec::new() + } else { + self.0.split('/').collect() + }; + + for seg in rel.as_str().split('/').filter(|s| !s.is_empty()) { + if seg == ".." { + segments.pop(); + } else { + segments.push(seg); + } + } + + Path(Cow::Owned(segments.join("/"))) + } } impl<'a> From<&'a str> for Path<'a> { @@ -320,6 +353,145 @@ impl<'de: 'a, 'a> serde::Deserialize<'de> for Path<'a> { } } +/// An owned version of [`PathRelative`] with a `'static` lifetime. +pub type PathRelativeOwned = PathRelative<'static>; + +/// A relative broadcast path, used to reference broadcasts from another broadcast's catalog. +/// +/// Unlike [`Path`] (which represents an absolute reference within the broadcast namespace), +/// `PathRelative` may contain `..` segments to walk up the namespace and is meaningful only +/// when resolved against a base [`Path`] via [`Path::resolve`]. +/// +/// `PathRelative` is intended for off-wire use (e.g. catalog references). It does not implement +/// `Encode`/`Decode` and should not appear in announce/subscribe messages. +/// +/// Leading and trailing slashes are trimmed on creation; consecutive internal slashes collapse +/// to a single slash, mirroring [`Path`]. +/// +/// # Examples +/// ``` +/// use moq_lite::{Path, PathRelative}; +/// +/// let rel = PathRelative::new("../source"); +/// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); +/// ``` +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct PathRelative<'a>(Cow<'a, str>); + +impl<'a> PathRelative<'a> { + /// Create a new `PathRelative` from a string slice. + /// + /// Leading and trailing slashes are trimmed. Multiple consecutive internal slashes collapse + /// to single slashes. + pub fn new(s: &'a str) -> Self { + let trimmed = s.trim_start_matches('/').trim_end_matches('/'); + + if trimmed.contains("//") { + let normalized = trimmed + .split('/') + .filter(|s| !s.is_empty()) + .collect::>() + .join("/"); + Self(Cow::Owned(normalized)) + } else { + Self(Cow::Borrowed(trimmed)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn empty() -> PathRelative<'static> { + PathRelative(Cow::Borrowed("")) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn to_owned(&self) -> PathRelativeOwned { + PathRelative(Cow::Owned(self.0.to_string())) + } + + pub fn into_owned(self) -> PathRelativeOwned { + PathRelative(Cow::Owned(self.0.into_owned())) + } + + pub fn borrow(&'a self) -> PathRelative<'a> { + PathRelative(Cow::Borrowed(&self.0)) + } +} + +impl<'a> From<&'a str> for PathRelative<'a> { + fn from(s: &'a str) -> Self { + Self::new(s) + } +} + +impl<'a> From<&'a String> for PathRelative<'a> { + fn from(s: &'a String) -> Self { + Self::new(s) + } +} + +impl From for PathRelative<'_> { + fn from(s: String) -> Self { + // Owned variant: avoid round-tripping through &str when no normalization is needed. + let trimmed = s.trim_start_matches('/').trim_end_matches('/'); + + if trimmed.contains("//") { + let normalized = trimmed + .split('/') + .filter(|s| !s.is_empty()) + .collect::>() + .join("/"); + Self(Cow::Owned(normalized)) + } else if trimmed == s { + Self(Cow::Owned(s)) + } else { + Self(Cow::Owned(trimmed.to_string())) + } + } +} + +impl Default for PathRelative<'_> { + fn default() -> Self { + Self(Cow::Borrowed("")) + } +} + +impl AsRef for PathRelative<'_> { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Display for PathRelative<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Owned-only deserialization. We use `String::deserialize` so that owned deserializers +// (e.g. `serde_json::from_slice`) work — the borrowed form `<&str>::deserialize` requires +// `'de: 'a`, which is unsatisfiable when `'a = 'static`. +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PathRelative<'static> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(PathRelative::from(s)) + } +} + /// A deduplicated list of path prefixes. /// /// Automatically removes exact duplicates and overlapping prefixes on construction. @@ -963,4 +1135,56 @@ mod tests { let b = PathPrefixes::new(["bar", "foo"]); assert_eq!(a, b); } + + #[test] + fn test_path_relative_normalize() { + assert_eq!(PathRelative::new("foo").as_str(), "foo"); + assert_eq!(PathRelative::new("/foo/").as_str(), "foo"); + assert_eq!(PathRelative::new("foo//bar").as_str(), "foo/bar"); + assert_eq!(PathRelative::new("../foo").as_str(), "../foo"); + assert_eq!(PathRelative::new("../../a/b").as_str(), "../../a/b"); + assert!(PathRelative::new("").is_empty()); + } + + #[test] + fn test_resolve_no_dotdot() { + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/b/c/d"); + } + + #[test] + fn test_resolve_empty_rel_returns_base() { + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("")).as_str(), "a/b"); + } + + #[test] + fn test_resolve_single_dotdot() { + let base = Path::new("a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a/b"); + } + + #[test] + fn test_resolve_multiple_dotdot() { + let base = Path::new("a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "a/x"); + assert_eq!(base.resolve(&PathRelative::new("../../../x")).as_str(), "x"); + } + + #[test] + fn test_resolve_dotdot_clamps_at_root() { + let base = Path::new("a"); + // Excess `..` clamps at the root, returning an empty / single-segment path. + assert_eq!(base.resolve(&PathRelative::new("../../../foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + } + + #[test] + fn test_resolve_empty_base() { + let base = Path::empty(); + assert_eq!(base.resolve(&PathRelative::new("foo")).as_str(), "foo"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + } } diff --git a/rs/moq-mux/src/import/aac.rs b/rs/moq-mux/src/import/aac.rs index 3476147e5..4d80466a6 100644 --- a/rs/moq-mux/src/import/aac.rs +++ b/rs/moq-mux/src/import/aac.rs @@ -120,6 +120,7 @@ impl Aac { let jitter = moq_lite::Time::from_micros(frame_duration_us).ok(); let audio_config = hang::catalog::AudioConfig { + broadcast: None, codec: hang::catalog::AAC { profile: config.profile, } diff --git a/rs/moq-mux/src/import/av01.rs b/rs/moq-mux/src/import/av01.rs index 8825b77c2..3c89cfd6b 100644 --- a/rs/moq-mux/src/import/av01.rs +++ b/rs/moq-mux/src/import/av01.rs @@ -52,6 +52,7 @@ impl Av01 { fn init(&mut self, seq_header: &SequenceHeaderObu) -> anyhow::Result<()> { let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(seq_header.max_frame_width as u32), coded_height: Some(seq_header.max_frame_height as u32), codec: hang::catalog::AV1 { @@ -112,6 +113,7 @@ impl Av01 { /// Initialize with minimal config if sequence header parsing fails fn init_minimal(&mut self) -> anyhow::Result<()> { let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: None, coded_height: None, codec: hang::catalog::AV1 { @@ -184,6 +186,7 @@ impl Av01 { let twelve_bit = ((data[2] >> 5) & 0x01) == 1; let config = hang::catalog::VideoConfig { + broadcast: None, // Resolution unknown from av1C - will be updated when first sequence header arrives coded_width: None, coded_height: None, diff --git a/rs/moq-mux/src/import/avc1.rs b/rs/moq-mux/src/import/avc1.rs index f9ab1f380..e3dedae8a 100644 --- a/rs/moq-mux/src/import/avc1.rs +++ b/rs/moq-mux/src/import/avc1.rs @@ -73,6 +73,7 @@ impl Avc1 { } let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: if width > 0 { Some(width) } else { None }, coded_height: if height > 0 { Some(height) } else { None }, codec: hang::catalog::H264 { diff --git a/rs/moq-mux/src/import/avc3.rs b/rs/moq-mux/src/import/avc3.rs index 7168013f6..481796935 100644 --- a/rs/moq-mux/src/import/avc3.rs +++ b/rs/moq-mux/src/import/avc3.rs @@ -70,6 +70,7 @@ impl Avc3 { | ((sps.constraint_set5_flag as u8) << 2); let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(sps.width), coded_height: Some(sps.height), codec: hang::catalog::H264 { diff --git a/rs/moq-mux/src/import/fmp4.rs b/rs/moq-mux/src/import/fmp4.rs index c1045fa5e..3fdd5ed91 100644 --- a/rs/moq-mux/src/import/fmp4.rs +++ b/rs/moq-mux/src/import/fmp4.rs @@ -267,6 +267,7 @@ impl Fmp4 { avcc.encode_body(&mut description)?; VideoConfig { + broadcast: None, coded_width: Some(avc1.visual.width as _), coded_height: Some(avc1.visual.height as _), codec: H264 { @@ -290,6 +291,7 @@ impl Fmp4 { mp4_atom::Codec::Hev1(hev1) => self.init_h265(true, &hev1.hvcc, &hev1.visual, container)?, mp4_atom::Codec::Hvc1(hvc1) => self.init_h265(false, &hvc1.hvcc, &hvc1.visual, container)?, mp4_atom::Codec::Vp08(vp08) => VideoConfig { + broadcast: None, codec: VideoCodec::VP8, description: Default::default(), coded_width: Some(vp08.visual.width as _), @@ -308,6 +310,7 @@ impl Fmp4 { let vpcc = &vp09.vpcc; VideoConfig { + broadcast: None, codec: VP9 { profile: vpcc.profile, level: vpcc.level, @@ -336,6 +339,7 @@ impl Fmp4 { let av1c = &av01.av1c; VideoConfig { + broadcast: None, codec: AV1 { profile: av1c.seq_profile, level: av1c.seq_level_idx_0, @@ -385,6 +389,7 @@ impl Fmp4 { hvcc.encode_body(&mut description)?; Ok(VideoConfig { + broadcast: None, codec: H265 { in_band, profile_space: hvcc.general_profile_space, @@ -438,6 +443,7 @@ impl Fmp4 { let description = build_aac_audio_specific_config(profile, sample_rate, channel_count); AudioConfig { + broadcast: None, codec: AAC { profile }.into(), sample_rate, channel_count, @@ -449,6 +455,7 @@ impl Fmp4 { } mp4_atom::Codec::Opus(opus) => { AudioConfig { + broadcast: None, codec: AudioCodec::Opus, sample_rate: opus.audio.sample_rate.integer() as _, channel_count: opus.audio.channel_count as _, diff --git a/rs/moq-mux/src/import/hev1.rs b/rs/moq-mux/src/import/hev1.rs index c2a474e6d..574680027 100644 --- a/rs/moq-mux/src/import/hev1.rs +++ b/rs/moq-mux/src/import/hev1.rs @@ -60,6 +60,7 @@ impl Hev1 { let vui_data = sps.rbsp.vui_parameters.as_ref().map(VuiData::new).unwrap_or_default(); let config = hang::catalog::VideoConfig { + broadcast: None, coded_width: Some(sps.rbsp.cropped_width() as u32), coded_height: Some(sps.rbsp.cropped_height() as u32), codec: hang::catalog::H265 { diff --git a/rs/moq-mux/src/import/opus.rs b/rs/moq-mux/src/import/opus.rs index 25c14b8a8..5834a3c51 100644 --- a/rs/moq-mux/src/import/opus.rs +++ b/rs/moq-mux/src/import/opus.rs @@ -58,6 +58,7 @@ impl Opus { let track = broadcast.unique_track(".opus")?; let audio_config = hang::catalog::AudioConfig { + broadcast: None, codec: hang::catalog::AudioCodec::Opus, sample_rate: config.sample_rate, channel_count: config.channel_count, diff --git a/rs/moq-mux/src/msf.rs b/rs/moq-mux/src/msf.rs index a924d2b3e..680045ec7 100644 --- a/rs/moq-mux/src/msf.rs +++ b/rs/moq-mux/src/msf.rs @@ -98,6 +98,7 @@ mod test { video_renditions.insert( "video0.avc3".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -122,6 +123,7 @@ mod test { audio_renditions.insert( "audio0".to_string(), AudioConfig { + broadcast: None, codec: AudioCodec::Opus, sample_rate: 48_000, channel_count: 2, @@ -177,6 +179,7 @@ mod test { video_renditions.insert( "video0.m4s".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, @@ -226,6 +229,7 @@ mod test { video_renditions.insert( "video0.m4s".to_string(), VideoConfig { + broadcast: None, codec: H264 { profile: 0x64, constraints: 0x00, From b1b7d7164e01a469e68b99db304e65d0c5e187a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 23 May 2026 17:42:08 -0700 Subject: [PATCH 2/2] hang: harden cross-broadcast track refs after self-review Addresses correctness, lifecycle, and parity issues in the catalog broadcast-override path: - watch: rewrite Broadcast.trackBroadcast so the override path doesn't depend on this.active, caches remote broadcasts per resolved path on this.signals (one shared subscription instead of one per backend), and treats self-references (rel resolving back to the catalog's own path) as a fall-through to active. - moq-lite: PathRelative::new and From now strip `.` segments alongside empty ones, matching POSIX intuition so `./foo` resolves to the expected path instead of a literal `.` segment. Path::resolve early-returns when rel is empty. - hang/examples/subscribe.rs: treat Some(empty), self-referential, and empty-resolved overrides as no-op so announced_broadcast can't be called on an empty path (which would hang forever). - hang catalog tests: strengthen the round-trip to actually re-decode the encoded output, and add tests for the None-omission and empty-string normalization paths. - hang catalog schemas: normalize the JS-side `broadcast` field via a shared zod transform that mirrors the Rust PathRelative normalization, so both sides agree byte-for-byte after deserialization. - moq-lite: fix em-dash in deserialize comment and tighten the PathRelative doc to clarify it has no Encode/Decode but does ride inside catalog JSON payloads. - Add bun tests for resolveBroadcast covering the same cases as the Rust resolve tests (dot, dotdot clamping, empty rel, empty base, self-reference). Follow-up: VideoConfig/AudioConfig should be #[non_exhaustive] with builders to avoid future SemVer breaks; deferred since it requires migrating all moq-mux import sites to a builder pattern. Written by Claude Co-Authored-By: Claude Opus 4.7 (1M context) --- js/hang/src/catalog/audio.ts | 3 +- js/hang/src/catalog/path.test.ts | 60 +++++++++++++++++++++ js/hang/src/catalog/path.ts | 35 ++++++++++-- js/hang/src/catalog/video.ts | 3 +- js/watch/src/broadcast.ts | 51 ++++++++++++++---- rs/hang/examples/subscribe.rs | 19 ++++--- rs/hang/src/catalog/root.rs | 76 ++++++++++++++++++++++++-- rs/moq-lite/src/path.rs | 92 +++++++++++++++++++++++--------- 8 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 js/hang/src/catalog/path.test.ts diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index f9a02f02f..d03946e2a 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -1,6 +1,7 @@ import * as z from "zod/mini"; import { ContainerSchema } from "./container"; import { u53Schema } from "./integers"; +import { RelativeBroadcastSchema } from "./path"; // Backwards compatibility: old track schema const TrackSchema = z.object({ @@ -13,7 +14,7 @@ export const AudioConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed // relative to the broadcast that served this catalog (e.g. "../source"). // If unset, the track lives in the same broadcast as the catalog. - broadcast: z.optional(z.string()), + broadcast: z.optional(RelativeBroadcastSchema), // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/hang/src/catalog/path.test.ts b/js/hang/src/catalog/path.test.ts new file mode 100644 index 000000000..528bf93eb --- /dev/null +++ b/js/hang/src/catalog/path.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { Path } from "@moq/lite"; +import { normalizeRelativeBroadcast, resolveBroadcast } from "./path.ts"; + +test("resolveBroadcast appends named segments", () => { + const base = Path.from("a/b"); + expect(resolveBroadcast(base, "c")).toBe(Path.from("a/b/c")); + expect(resolveBroadcast(base, "c/d")).toBe(Path.from("a/b/c/d")); +}); + +test("resolveBroadcast with empty rel returns base", () => { + expect(resolveBroadcast(Path.from("a/b"), "")).toBe(Path.from("a/b")); +}); + +test("resolveBroadcast single dotdot pops one segment", () => { + const base = Path.from("a/b/c"); + expect(resolveBroadcast(base, "../d")).toBe(Path.from("a/b/d")); + expect(resolveBroadcast(base, "..")).toBe(Path.from("a/b")); +}); + +test("resolveBroadcast multiple dotdot pops multiple segments", () => { + const base = Path.from("a/b/c"); + expect(resolveBroadcast(base, "../../x")).toBe(Path.from("a/x")); + expect(resolveBroadcast(base, "../../../x")).toBe(Path.from("x")); +}); + +test("resolveBroadcast excess dotdot clamps at empty", () => { + const base = Path.from("a"); + expect(resolveBroadcast(base, "../../../foo")).toBe(Path.from("foo")); + expect(resolveBroadcast(base, "..")).toBe(Path.from("")); +}); + +test("resolveBroadcast with empty base", () => { + const base = Path.from(""); + expect(resolveBroadcast(base, "foo")).toBe(Path.from("foo")); + expect(resolveBroadcast(base, "..")).toBe(Path.from("")); +}); + +test("resolveBroadcast treats dot as a no-op", () => { + const base = Path.from("a/b"); + expect(resolveBroadcast(base, ".")).toBe(Path.from("a/b")); + expect(resolveBroadcast(base, "./c")).toBe(Path.from("a/b/c")); + expect(resolveBroadcast(base, "./../c")).toBe(Path.from("a/c")); + expect(resolveBroadcast(base, "foo/./bar")).toBe(Path.from("a/b/foo/bar")); +}); + +test("resolveBroadcast self-reference via dotdot equals base", () => { + const base = Path.from("a/b"); + expect(resolveBroadcast(base, "../b")).toBe(base); +}); + +test("normalizeRelativeBroadcast drops empty and dot segments", () => { + expect(normalizeRelativeBroadcast("")).toBe(""); + expect(normalizeRelativeBroadcast(".")).toBe(""); + expect(normalizeRelativeBroadcast("./foo")).toBe("foo"); + expect(normalizeRelativeBroadcast("foo//bar")).toBe("foo/bar"); + expect(normalizeRelativeBroadcast("foo/./bar")).toBe("foo/bar"); + expect(normalizeRelativeBroadcast("/foo/")).toBe("foo"); + expect(normalizeRelativeBroadcast("../foo")).toBe("../foo"); +}); diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index c7112f34e..1fd14eac0 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -1,11 +1,28 @@ import { Path } from "@moq/lite"; +import * as z from "zod/mini"; + +/** + * Normalize a relative broadcast string the way Rust `PathRelative::new` does: trim + * leading/trailing slashes, drop empty segments, and drop `.` segments. `..` is preserved + * and only interpreted by `resolveBroadcast`. + * + * Returns the normalized form. Two callers comparing normalized strings can detect that + * `""`, `"."`, `"/./"` etc. all mean "no override". + */ +export function normalizeRelativeBroadcast(rel: string): string { + return rel + .split("/") + .filter((s) => s !== "" && s !== ".") + .join("/"); +} /** * Resolve a relative broadcast reference against the path of the broadcast that served the catalog. * - * `..` segments pop the last segment of the base path; named segments are appended. - * Excess `..` clamps at the root, returning an empty path. An empty `rel` returns the base - * path unchanged. + * `..` segments pop the last segment of the base path; other segments are appended. + * `.` and empty segments are no-ops. Excess `..` once the base is empty is also a no-op + * (subsequent named segments still append). An empty / normalized-empty `rel` returns the + * base path unchanged. * * Mirrors the Rust `Path::resolve(&PathRelative)` helper used by hang catalogs to express * cross-broadcast track references. @@ -15,11 +32,19 @@ import { Path } from "@moq/lite"; * resolveBroadcast(Path.from("a/b/c"), "../source"); // "a/b/source" * resolveBroadcast(Path.from("a/b"), "x/y"); // "a/b/x/y" * resolveBroadcast(Path.from("a"), "../../x"); // "x" + * resolveBroadcast(Path.from("a/b"), "./c"); // "a/b/c" * ``` */ +/** + * Zod schema for a relative broadcast reference stored in a catalog. Normalizes the input + * the same way Rust `PathRelative::new` does so JS and Rust agree byte-for-byte on what's + * stored in memory after deserialization. + */ +export const RelativeBroadcastSchema = z.pipe(z.string(), z.transform(normalizeRelativeBroadcast)); + export function resolveBroadcast(base: Path.Valid, rel: string): Path.Valid { - const baseSegments = base === "" ? [] : base.split("/"); - const relSegments = rel.split("/").filter((s) => s !== ""); + const baseSegments = base === "" ? [] : base.split("/").filter((s) => s !== ""); + const relSegments = rel.split("/").filter((s) => s !== "" && s !== "."); for (const seg of relSegments) { if (seg === "..") { diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 0c746229d..cda18b61d 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -1,6 +1,7 @@ import * as z from "zod/mini"; import { ContainerSchema } from "./container"; import { u53Schema } from "./integers"; +import { RelativeBroadcastSchema } from "./path"; // Backwards compatibility: old track schema const TrackSchema = z.object({ @@ -12,7 +13,7 @@ export const VideoConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed // relative to the broadcast that served this catalog (e.g. "../source"). // If unset, the track lives in the same broadcast as the catalog. - broadcast: z.optional(z.string()), + broadcast: z.optional(RelativeBroadcastSchema), // See: https://w3c.github.io/webcodecs/codec_registry.html codec: z.string(), diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index d286f8586..a9310e138 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -75,6 +75,11 @@ export class Broadcast { } #isAnnounced(effect: Effect): boolean { + const name = effect.get(this.name); + return this.#isPathAnnounced(effect, name); + } + + #isPathAnnounced(effect: Effect, name: Path.Valid): boolean { const reload = effect.get(this.reload); if (!reload) return true; @@ -87,7 +92,6 @@ export class Broadcast { return true; } - const name = effect.get(this.name); const announced = effect.get(this.#announced); return announced.has(name); } @@ -166,21 +170,46 @@ export class Broadcast { * subscribe to the resolved broadcast on the same connection. Otherwise return the catalog's * own active broadcast. * - * The lifetime of any newly-opened broadcast is tied to the provided `Effect`. + * Override broadcasts are cached per resolved path and owned by this Broadcast's + * `signals`; the caller's `effect` only subscribes to the cached signal. This means + * many renditions referencing the same source share one underlying subscription, and + * the override outlives any single caller effect. */ trackBroadcast(effect: Effect, configBroadcast: string | undefined): Moq.Broadcast | undefined { - const active = effect.get(this.active); - if (!active) return undefined; - if (!configBroadcast) return active; - - const conn = effect.get(this.connection); - if (!conn) return undefined; + if (!configBroadcast) return effect.get(this.active); const basePath = effect.get(this.name); const resolved = Catalog.resolveBroadcast(basePath, configBroadcast); - const remote = conn.consume(resolved); - effect.cleanup(() => remote.close()); - return remote; + + // Self-reference (including `..` paths that walk back to the catalog's own path, + // and any rel that normalizes to empty): reuse the catalog's broadcast handle + // instead of opening a duplicate subscription on the same path. + if (resolved === basePath) return effect.get(this.active); + + return effect.get(this.#override(resolved)); + } + + #overrides = new Map>(); + + #override(path: Path.Valid): Signal { + const cached = this.#overrides.get(path); + if (cached) return cached; + + const signal = new Signal(undefined); + this.#overrides.set(path, signal); + + this.signals.run((effect) => { + const conn = effect.get(this.connection); + if (!conn) return; + + if (!this.#isPathAnnounced(effect, path)) return; + + const remote = conn.consume(path); + effect.cleanup(() => remote.close()); + effect.set(signal, remote, undefined); + }); + + return signal; } close() { diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index a7cf8c736..a77ee57b7 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -75,15 +75,22 @@ async fn run_subscribe(mut consumer: moq_lite::OriginConsumer) -> anyhow::Result // If the rendition references a different broadcast (e.g. a source feed that this // catalog only sidecars), resolve it relative to the catalog's broadcast path and // wait for the announcement. Otherwise subscribe on the catalog's broadcast. + // Treat an empty rel, a rel that resolves back to our own path, or a rel that + // resolves to empty (excess `..`) as "no override" so we reuse the existing + // broadcast handle instead of opening a redundant or unrouteable subscription. let track_broadcast = match config.broadcast.as_ref() { - Some(rel) => { + Some(rel) if !rel.is_empty() => { let resolved = path.resolve(rel); - consumer - .announced_broadcast(&resolved) - .await - .ok_or_else(|| anyhow::anyhow!("source broadcast unavailable: {resolved}"))? + if resolved.is_empty() || resolved == path { + broadcast.clone() + } else { + consumer + .announced_broadcast(&resolved) + .await + .ok_or_else(|| anyhow::anyhow!("source broadcast unavailable: {resolved}"))? + } } - None => broadcast.clone(), + _ => broadcast.clone(), }; // Subscribe to the video track. diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index a1a3cb3f8..05d69b59f 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -218,11 +218,81 @@ mod test { "broadcast field did not deserialize" ); - // Re-encoding preserves the broadcast field. + // Full encode -> decode -> equality, so the test catches any encoder regression + // (e.g. wrong key, double-emission, or `null` instead of skip). let output = parsed.to_string().expect("failed to encode"); + let reparsed = Catalog::from_str(&output).expect("failed to re-decode"); + assert_eq!(parsed, reparsed, "re-encoded catalog did not round-trip"); + } + + #[test] + fn rendition_without_broadcast_omits_field() { + // `broadcast: None` must NOT serialize as `"broadcast":null`, otherwise the wire + // format silently changes for every catalog that doesn't use cross-broadcast refs. + let mut renditions = BTreeMap::new(); + renditions.insert( + "video".to_string(), + VideoConfig { + broadcast: None, + codec: H264 { + profile: 0x64, + constraints: 0x00, + level: 0x1f, + inline: false, + } + .into(), + description: None, + coded_width: Some(1280), + coded_height: Some(720), + display_ratio_width: None, + display_ratio_height: None, + bitrate: None, + framerate: None, + optimize_for_latency: None, + container: Container::Legacy, + jitter: None, + }, + ); + + let catalog = Catalog { + video: Video { + renditions, + ..Default::default() + }, + ..Default::default() + }; + + let output = catalog.to_string().expect("failed to encode"); assert!( - output.contains(r#""broadcast":"../source""#), - "broadcast field missing from re-encoded JSON: {output}" + !output.contains("broadcast"), + "broadcast field leaked into JSON when None: {output}" + ); + } + + #[test] + fn rendition_with_empty_broadcast_normalizes() { + // An empty-string broadcast field should normalize to an empty PathRelative so the + // consumer can treat it identically to a missing field. + let encoded = r#"{ + "video": { + "renditions": { + "video": { + "broadcast": "", + "codec": "avc1.64001f", + "codedWidth": 1280, + "codedHeight": 720, + "container": {"kind": "legacy"} + } + } + } + }"#; + + let parsed = Catalog::from_str(encoded).expect("failed to decode"); + let rendition = parsed.video.renditions.get("video").expect("missing rendition"); + assert_eq!( + rendition.broadcast.as_ref().map(|p| p.is_empty()), + Some(true), + "empty broadcast should deserialize as Some(empty)" ); } } diff --git a/rs/moq-lite/src/path.rs b/rs/moq-lite/src/path.rs index ba1d4c2d6..9dcd22d1a 100644 --- a/rs/moq-lite/src/path.rs +++ b/rs/moq-lite/src/path.rs @@ -235,9 +235,11 @@ impl<'a> Path<'a> { /// Resolve a [`PathRelative`] against this path. /// - /// `..` segments in `rel` pop the last segment of the base; named segments are appended. - /// Excess `..` clamps at the root, returning an empty path. An empty `rel` returns this - /// path unchanged (as an owned copy). + /// `..` segments in `rel` pop the last segment of the base; other segments are appended. + /// Excess `..` is a no-op once the base is empty (subsequent named segments still append). + /// An empty `rel` returns this path as an owned copy. + /// + /// [`PathRelative::new`] strips `.` and empty segments, so they are not handled here. /// /// # Examples /// ``` @@ -249,13 +251,17 @@ impl<'a> Path<'a> { /// assert_eq!(base.resolve(&PathRelative::new("../../../../x")).as_str(), "x"); /// ``` pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { + if rel.is_empty() { + return self.to_owned(); + } + let mut segments: Vec<&str> = if self.0.is_empty() { Vec::new() } else { self.0.split('/').collect() }; - for seg in rel.as_str().split('/').filter(|s| !s.is_empty()) { + for seg in rel.as_str().split('/') { if seg == ".." { segments.pop(); } else { @@ -362,11 +368,13 @@ pub type PathRelativeOwned = PathRelative<'static>; /// `PathRelative` may contain `..` segments to walk up the namespace and is meaningful only /// when resolved against a base [`Path`] via [`Path::resolve`]. /// -/// `PathRelative` is intended for off-wire use (e.g. catalog references). It does not implement -/// `Encode`/`Decode` and should not appear in announce/subscribe messages. +/// `PathRelative` has no `Encode`/`Decode` impl, so it never appears in announce/subscribe +/// frames. It does serialize via serde for off-wire use (e.g. as a field inside a catalog +/// JSON payload, which itself travels as a track). /// -/// Leading and trailing slashes are trimmed on creation; consecutive internal slashes collapse -/// to a single slash, mirroring [`Path`]. +/// Normalization on creation: leading/trailing slashes are trimmed, consecutive internal +/// slashes collapse to one, and `.` segments are stripped (treated as no-ops, matching +/// POSIX). `..` is preserved and is interpreted at resolve time. /// /// # Examples /// ``` @@ -374,6 +382,9 @@ pub type PathRelativeOwned = PathRelative<'static>; /// /// let rel = PathRelative::new("../source"); /// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); +/// +/// // `.` segments are stripped on creation. +/// assert_eq!(PathRelative::new("./a/./b").as_str(), "a/b"); /// ``` #[derive(Debug, PartialEq, Eq, Hash, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] @@ -382,18 +393,13 @@ pub struct PathRelative<'a>(Cow<'a, str>); impl<'a> PathRelative<'a> { /// Create a new `PathRelative` from a string slice. /// - /// Leading and trailing slashes are trimmed. Multiple consecutive internal slashes collapse - /// to single slashes. + /// Leading and trailing slashes are trimmed, consecutive internal slashes collapse to one, + /// and `.` segments are stripped. See the type-level doc for the full normalization rules. pub fn new(s: &'a str) -> Self { let trimmed = s.trim_start_matches('/').trim_end_matches('/'); - if trimmed.contains("//") { - let normalized = trimmed - .split('/') - .filter(|s| !s.is_empty()) - .collect::>() - .join("/"); - Self(Cow::Owned(normalized)) + if needs_normalize_relative(trimmed) { + Self(Cow::Owned(normalize_relative_segments(trimmed))) } else { Self(Cow::Borrowed(trimmed)) } @@ -442,16 +448,10 @@ impl<'a> From<&'a String> for PathRelative<'a> { impl From for PathRelative<'_> { fn from(s: String) -> Self { - // Owned variant: avoid round-tripping through &str when no normalization is needed. let trimmed = s.trim_start_matches('/').trim_end_matches('/'); - if trimmed.contains("//") { - let normalized = trimmed - .split('/') - .filter(|s| !s.is_empty()) - .collect::>() - .join("/"); - Self(Cow::Owned(normalized)) + if needs_normalize_relative(trimmed) { + Self(Cow::Owned(normalize_relative_segments(trimmed))) } else if trimmed == s { Self(Cow::Owned(s)) } else { @@ -460,6 +460,18 @@ impl From for PathRelative<'_> { } } +fn needs_normalize_relative(trimmed: &str) -> bool { + trimmed.split('/').any(|seg| seg.is_empty() || seg == ".") +} + +fn normalize_relative_segments(trimmed: &str) -> String { + trimmed + .split('/') + .filter(|seg| !seg.is_empty() && *seg != ".") + .collect::>() + .join("/") +} + impl Default for PathRelative<'_> { fn default() -> Self { Self(Cow::Borrowed("")) @@ -479,7 +491,7 @@ impl Display for PathRelative<'_> { } // Owned-only deserialization. We use `String::deserialize` so that owned deserializers -// (e.g. `serde_json::from_slice`) work — the borrowed form `<&str>::deserialize` requires +// (e.g. `serde_json::from_slice`) work. The borrowed form `<&str>::deserialize` requires // `'de: 'a`, which is unsatisfiable when `'a = 'static`. #[cfg(feature = "serde")] impl<'de> serde::Deserialize<'de> for PathRelative<'static> { @@ -1146,6 +1158,17 @@ mod tests { assert!(PathRelative::new("").is_empty()); } + #[test] + fn test_path_relative_strips_dot_segments() { + assert_eq!(PathRelative::new(".").as_str(), ""); + assert_eq!(PathRelative::new("./foo").as_str(), "foo"); + assert_eq!(PathRelative::new("foo/./bar").as_str(), "foo/bar"); + assert_eq!(PathRelative::new("./../foo").as_str(), "../foo"); + // From path takes the same normalization. + assert_eq!(PathRelative::from("./foo".to_string()).as_str(), "foo"); + assert_eq!(PathRelative::from(".".to_string()).as_str(), ""); + } + #[test] fn test_resolve_no_dotdot() { let base = Path::new("a/b"); @@ -1187,4 +1210,21 @@ mod tests { assert_eq!(base.resolve(&PathRelative::new("foo")).as_str(), "foo"); assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); } + + #[test] + fn test_resolve_dot_is_noop() { + let base = Path::new("a/b"); + // `.` is normalized away by PathRelative::new, so resolve ignores it. + assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a/b"); + assert_eq!(base.resolve(&PathRelative::new("./c")).as_str(), "a/b/c"); + assert_eq!(base.resolve(&PathRelative::new("./../c")).as_str(), "a/c"); + } + + #[test] + fn test_resolve_self_reference_via_dotdot() { + // Walking `..` back to the same path yields the base unchanged, which lets the + // caller compare resolved == base to detect a self-reference. + let base = Path::new("a/b"); + assert_eq!(base.resolve(&PathRelative::new("../b")).as_str(), "a/b"); + } }