diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index 47178addc..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({ @@ -10,6 +11,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(RelativeBroadcastSchema), + // 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 e63ea4f1a..6380587f8 100644 --- a/js/hang/src/catalog/index.ts +++ b/js/hang/src/catalog/index.ts @@ -5,6 +5,7 @@ export * from "./container"; export * from "./format"; 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.test.ts b/js/hang/src/catalog/path.test.ts new file mode 100644 index 000000000..2e8cf17ae --- /dev/null +++ b/js/hang/src/catalog/path.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { Path } from "@moq/net"; +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 new file mode 100644 index 000000000..abfb5483a --- /dev/null +++ b/js/hang/src/catalog/path.ts @@ -0,0 +1,58 @@ +import { Path } from "@moq/net"; +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; 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. + * + * @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" + * 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("/").filter((s) => s !== ""); + const relSegments = rel.split("/").filter((s) => 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..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({ @@ -9,6 +10,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(RelativeBroadcastSchema), + // 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 ce2ec2479..6318ed9b6 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -175,7 +175,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 c63517e1c..1c476fe4a 100644 --- a/js/watch/src/audio/mse.ts +++ b/js/watch/src/audio/mse.ts @@ -53,15 +53,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 dfdec3f00..2035a3876 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -92,11 +92,16 @@ export class Broadcast { } #runAnnouncedNow(effect: Effect): void { + const name = effect.get(this.name); + this.#announcedNow.set(this.#isPathAnnounced(effect, name)); + } + + // Whether `name` is currently announced on the connection (or skipping the check + // because reload is off or the relay doesn't support announcements). Used by both + // `#runAnnouncedNow` (for `this.name`) and `#override` (for cross-broadcast refs). + #isPathAnnounced(effect: Effect, name: Path.Valid): boolean { const reload = effect.get(this.reload); - if (!reload) { - this.#announcedNow.set(true); - return; - } + if (!reload) return true; // Cloudflare's relay does not yet support announcement subscriptions, // so an announcement will never arrive. Fall back to subscribing @@ -104,13 +109,11 @@ export class Broadcast { const conn = effect.get(this.connection); if (conn?.url.hostname.endsWith("mediaoverquic.com")) { console.warn("Cloudflare relay does not support broadcast discovery yet; ignoring reload signal."); - this.#announcedNow.set(true); - return; + return true; } - const name = effect.get(this.name); const announced = effect.get(this.#announced); - this.#announcedNow.set(announced.has(name)); + return announced.has(name); } #runBroadcast(effect: Effect): void { @@ -184,6 +187,55 @@ 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. + * + * 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 { + if (!configBroadcast) return effect.get(this.active); + + const basePath = effect.get(this.name); + const resolved = Catalog.resolveBroadcast(basePath, configBroadcast); + + // 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() { this.signals.close(); } diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 358d120c7..4b9711822 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -83,7 +83,9 @@ export class Decoder implements Backend { } const [_, source, track, config] = values; - const broadcast: Moq.Broadcast | undefined = 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: Moq.Broadcast | undefined = source.trackBroadcast(effect, config.broadcast); if (!broadcast) { // Going offline should clear the last rendered frame. this.#active.set(undefined); diff --git a/js/watch/src/video/mse.ts b/js/watch/src/video/mse.ts index d6ac3205d..21f5299a1 100644 --- a/js/watch/src/video/mse.ts +++ b/js/watch/src/video/mse.ts @@ -51,15 +51,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 571823ee1..8194fe3cb 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -68,16 +68,38 @@ async fn run_subscribe(mut consumer: moq_net::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. + // 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) if !rel.is_empty() => { + let resolved = path.resolve(rel); + if resolved.is_empty() || resolved == path { + broadcast.clone() + } else { + consumer + .announced_broadcast(&resolved) + .await + .ok_or_else(|| anyhow::anyhow!("source broadcast unavailable: {resolved}"))? + } + } + _ => broadcast.clone(), + }; + // Subscribe to the video track. let track = moq_net::Track { name: name.clone(), priority: 1, }; - let track_consumer = broadcast.subscribe_track(&track)?; + let track_consumer = track_broadcast.subscribe_track(&track)?; let mut ordered = moq_mux::container::Consumer::new(track_consumer, moq_mux::catalog::hang::Container::Legacy) .with_latency(Duration::from_millis(500)); diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 4a7d0767b..81cac58f1 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -61,6 +61,12 @@ impl Audio { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct AudioConfig { + /// 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. + #[serde(default)] + pub broadcast: Option, + // The codec, see the registry for details: // https://w3c.github.io/webcodecs/codec_registry.html #[serde_as(as = "DisplayFromStr")] @@ -107,6 +113,7 @@ impl AudioConfig { /// since the type is `#[non_exhaustive]`. pub fn new(codec: impl Into, sample_rate: u32, channel_count: u32) -> Self { Self { + broadcast: None, codec: codec.into(), sample_rate, channel_count, diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 22e5925b0..b24e6387f 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -164,4 +164,96 @@ 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" + ); + + // 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 video_config = VideoConfig::new(H264 { + profile: 0x64, + constraints: 0x00, + level: 0x1f, + inline: false, + }); + video_config.coded_width = Some(1280); + video_config.coded_height = Some(720); + video_config.container = Container::Legacy; + + let mut renditions = BTreeMap::new(); + renditions.insert("video".to_string(), video_config); + + let catalog = Catalog { + video: Video { + renditions, + ..Default::default() + }, + ..Default::default() + }; + + let output = catalog.to_string().expect("failed to encode"); + assert!( + !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/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 0e7860fc0..84cbb7090 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -90,6 +90,15 @@ pub struct Display { #[serde(rename_all = "camelCase")] #[non_exhaustive] 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")] @@ -158,6 +167,7 @@ impl VideoConfig { /// since the type is `#[non_exhaustive]`. pub fn new(codec: impl Into) -> Self { Self { + broadcast: None, codec: codec.into(), description: None, coded_width: None, diff --git a/rs/moq-net/src/path.rs b/rs/moq-net/src/path.rs index 94fd37f03..c72f787b4 100644 --- a/rs/moq-net/src/path.rs +++ b/rs/moq-net/src/path.rs @@ -232,6 +232,45 @@ 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; 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 + /// ``` + /// 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 { + 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('/') { + if seg == ".." { + segments.pop(); + } else { + segments.push(seg); + } + } + + Path(Cow::Owned(segments.join("/"))) + } } impl<'a> From<&'a str> for Path<'a> { @@ -320,6 +359,151 @@ 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` 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). +/// +/// 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 +/// ``` +/// use moq_lite::{Path, PathRelative}; +/// +/// 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))] +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, 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 needs_normalize_relative(trimmed) { + Self(Cow::Owned(normalize_relative_segments(trimmed))) + } 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 { + let trimmed = s.trim_start_matches('/').trim_end_matches('/'); + + if needs_normalize_relative(trimmed) { + Self(Cow::Owned(normalize_relative_segments(trimmed))) + } else if trimmed == s { + Self(Cow::Owned(s)) + } else { + Self(Cow::Owned(trimmed.to_string())) + } + } +} + +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("")) + } +} + +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 +1147,84 @@ 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_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"); + 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(), ""); + } + + #[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"); + } }