diff --git a/src/lib.rs b/src/lib.rs index 0c06d04..e14807b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,7 +32,7 @@ //! let opts = libfreemkv::InputOptions::default(); //! let mut input = libfreemkv::input("iso://disc.iso", &opts)?; //! let title = input.info().clone(); -//! let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?; +//! let mut output = libfreemkv::output("mkv://Movie.mkv", &title, None)?; //! // Propagate read errors instead of silently stopping on the first one. //! while let Some(frame) = input.read()? { //! output.write(&frame)?; @@ -260,6 +260,7 @@ pub use mux::NullStream; pub use mux::StdioStream; pub use mux::WriteSeek; pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; +pub use mux::{Medium, SourceInfo}; pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report}; // ─── Lower-level surfaces ─────────────────────────────────────────────────── diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 2e5d975..e6d407d 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -41,6 +41,24 @@ use crate::session::DiscSession; use super::resolve::{ InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map, }; +use super::videomap::{Medium, SourceInfo}; + +/// The source medium a parsed input URL denotes (`docs/FVI_FORMAT.md` §6.2). +/// +/// Only used for provenance, so the mapping is by what the bytes physically came +/// FROM: an optical disc, a disc image, a local container file, or a +/// non-seekable byte stream. A URL that is not a legal input at all +/// (`input()` rejects it) never reaches a sink, so its arm is immaterial — +/// it falls to the `File` default. +fn url_medium(parsed: &StreamUrl) -> Medium { + match parsed { + StreamUrl::Disc { .. } => Medium::Disc, + StreamUrl::Iso { .. } => Medium::Iso, + StreamUrl::Mkv { .. } | StreamUrl::M2ts { .. } | StreamUrl::Mp4 { .. } => Medium::File, + StreamUrl::Network { .. } | StreamUrl::Stdio => Medium::Stream, + _ => Medium::File, + } +} /// Effectively-unbounded per-frame send deadline used when a consumer passes /// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network @@ -299,194 +317,265 @@ pub fn mux_stream( // into one (`reader_event_fn`) so the highway's producer thread — and the // live `DiscStream`'s read loop — forward `BytesRead`/`SectorSkipped`/ // `BatchSizeChanged`/`ReadError` back to the consumer's handle. - let (stream, playlist_name): (Box, Option) = match input_src { - // The Url path builds its demux INSIDE `input()`, which prunes to the - // selected streams via `InputOptions.selection` (which the caller sets). - // Note: `MuxOptions.selection` does NOT apply here — that's the File/ - // Session arms' field; a Url-source caller must set `InputOptions.selection`. - MuxInput::Url { url, opts: in_opts } => (input(url, &in_opts)?, None), - MuxInput::Iso { - path, - title, - format, - keys, - key_fetch, - } => { - // Prune to the selected audio/subtitle streams BEFORE the highway - // builds its demux state from `title.streams` (and before - // `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3 - // PIDs). Video is always kept; a no-op for the default All/All. - let mut title = title; - opts.selection - .apply(&mut title) - .map_err(std::io::Error::from)?; - let reader = FileSectorSource::open(path)?; - let stream = build_iso_pipeline( - reader, + // + // Each arm also derives the run's PROVENANCE (`SourceInfo`) — the facts a + // `fvi://` destination records in its header (`docs/FVI_FORMAT.md` §6.2). + // `output()` has no access to the source, so the driver is the only place + // that can supply it honestly; a member the arm genuinely cannot reach is + // left at its default rather than guessed (the sink omits the empty ones). + let (stream, playlist_name, mut source): (Box, Option, SourceInfo) = + match input_src { + // The Url path builds its demux INSIDE `input()`, which prunes to the + // selected streams via `InputOptions.selection` (which the caller sets). + // Note: `MuxOptions.selection` does NOT apply here — that's the File/ + // Session arms' field; a Url-source caller must set `InputOptions.selection`. + MuxInput::Url { url, opts: in_opts } => { + // Provenance: the source URL verbatim, its scheme's medium, and the + // title `input()` will open (`None` selects title 0, per its doc). + // `playlist` is filled in below from the opened stream's scanned + // title — it is not known until the scan runs. + let source = SourceInfo { + medium: url_medium(&parse_url(url)), + path: url.to_string(), + title: in_opts.title_index.unwrap_or(0), + ..SourceInfo::default() + }; + let stream = input(url, &in_opts)?; + let source = SourceInfo { + playlist: stream.info().playlist.clone(), + ..source + }; + (stream, None, source) + } + MuxInput::Iso { + path, title, - keys, - opts.batch_sectors, format, - opts.raw, - Some(halt.clone()), - Some(reader_event_fn(events.clone())), + keys, key_fetch, - )?; - (Box::new(stream), None) - } - MuxInput::Session { - session, - title_index, - } => { - // Pull everything we need out of the disc as owned values so the - // immutable disc borrow is released before the mutable - // `take_reader` below. - let (mut title, format, mut keys, playlist) = { - let disc = session.disc().ok_or_else(|| Error::DeviceNotReady { + } => { + // Prune to the selected audio/subtitle streams BEFORE the highway + // builds its demux state from `title.streams` (and before + // `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3 + // PIDs). Video is always kept; a no-op for the default All/All. + let mut title = title; + opts.selection + .apply(&mut title) + .map_err(std::io::Error::from)?; + // Provenance: the staged image, in the same `iso://` URL form the + // `Url` arm records, plus the title's playlist. The 0-based TITLE + // INDEX is genuinely NOT reachable here — `MuxInput::Iso` carries an + // already-scanned `DiscTitle`, which has no index, and the caller's + // position in `disc.titles` is not passed. It stays 0 rather than + // being guessed; adding it would be an additive field on the public + // `MuxInput::Iso` variant. + let source = SourceInfo { + medium: Medium::Iso, + path: format!("iso://{}", path.display()), + playlist: title.playlist.clone(), + ..SourceInfo::default() + }; + let reader = FileSectorSource::open(path)?; + let stream = build_iso_pipeline( + reader, + title, + keys, + opts.batch_sectors, + format, + opts.raw, + Some(halt.clone()), + Some(reader_event_fn(events.clone())), + key_fetch, + )?; + (Box::new(stream), None, source) + } + MuxInput::Session { + session, + title_index, + } => { + // Pull everything we need out of the disc as owned values so the + // immutable disc borrow is released before the mutable + // `take_reader` below. + let (mut title, format, mut keys, playlist, source) = { + let disc = session.disc().ok_or_else(|| Error::DeviceNotReady { + path: session.device_path().to_string(), + })?; + let title = + disc.titles + .get(title_index) + .cloned() + .ok_or(Error::MuxTrackRange { + track: title_index, + tracks: disc.titles.len(), + })?; + let playlist = disc + .meta_title + .clone() + .unwrap_or_else(|| disc.volume_id.clone()); + // Provenance: every member is reachable on this arm — the device + // the session is bound to, the title index the caller named, the + // title's own playlist, and the scanned volume id. + let source = SourceInfo { + medium: Medium::Disc, + path: format!("disc://{}", session.device_path()), + title: title_index, + playlist: title.playlist.clone(), + volume_id: disc.volume_id.clone(), + }; + // DVD CSS is per-VTS: resolve the per-title key via the pipeline + // (see `session_mux_keys`), never the whole-disc `decrypt_keys()`. + ( + title, + disc.content_format, + session_mux_keys(disc), + playlist, + source, + ) + }; + // Prune to the selected streams before `DiscStream::new` builds its + // demux tables from `title.streams` (and before its inline + // `probe_and_remap`). Only touches the stream list, so the + // ciphertext sampling in `resolve_inline_base_map` (keyed on extents) + // is unaffected. No-op for the default All/All. + opts.selection + .apply(&mut title) + .map_err(std::io::Error::from)?; + // A missing staged reader ("already consumed" / never staged) is a + // clean error, not a panic (contract Q2). + let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { path: session.device_path().to_string(), })?; - let title = disc - .titles - .get(title_index) - .cloned() - .ok_or(Error::MuxTrackRange { - track: title_index, - tracks: disc.titles.len(), - })?; - let playlist = disc - .meta_title - .clone() - .unwrap_or_else(|| disc.volume_id.clone()); - // DVD CSS is per-VTS: resolve the per-title key via the pipeline - // (see `session_mux_keys`), never the whole-disc `decrypt_keys()`. - (title, disc.content_format, session_mux_keys(disc), playlist) - }; - // Prune to the selected streams before `DiscStream::new` builds its - // demux tables from `title.streams` (and before its inline - // `probe_and_remap`). Only touches the stream list, so the - // ciphertext sampling in `resolve_inline_base_map` (keyed on extents) - // is unaffected. No-op for the default All/All. - opts.selection - .apply(&mut title) - .map_err(std::io::Error::from)?; - // A missing staged reader ("already consumed" / never staged) is a - // clean error, not a panic (contract Q2). - let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { - path: session.device_path().to_string(), - })?; - // Resolve the AACS key map off the STAGED reader BEFORE it is moved - // into `DiscStream::new` (borrow to sample, then move to construct). - // Without this the AACS `DecryptingSectorSource` inside the stream has - // no map and fails `DecryptFailed` on the first content unit — the - // single-pass live-mux decrypt bug. `session.key_fetch()` (retained by - // `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is - // missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in - // `DiscStream::new`; raw is ciphertext passthrough) — unchanged. - let base_map = resolve_inline_base_map( - &mut *reader, - &title, - &mut keys, - session.key_fetch(), - format, - opts.raw, - Some(halt), - )?; - let mut stream = crate::mux::DiscStream::new( - reader, - title, - keys, - opts.batch_sectors, - format, - opts.raw, - Some(halt.clone()), - )?; - if opts.raw { - stream.set_raw(); - } - if let Some(map) = base_map { - stream = stream.with_key_map(map); - } - stream.skip_errors = opts.skip_errors; - // Live path: the `DiscStream` emits the full reader-side vocabulary - // (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the - // adaptive sizer, `BytesRead` progress) — forward them all. - stream.on_event(reader_event_fn(events.clone())); - (Box::new(stream), Some(playlist)) - } - MuxInput::Live { - mut reader, - title, - format, - mut keys, - key_map, - } => { - // Prune to the selected streams, exactly as the Iso and Session arms - // do. Without this a caller's audio/subtitle selection was silently - // ignored on the live-drive path while the field's own doc said it was - // applied. Only touches the stream list, so the extent-keyed ciphertext - // sampling in `resolve_inline_base_map` below is unaffected. No-op for - // the default All/All. - let mut title = title; - opts.selection - .apply(&mut title) - .map_err(std::io::Error::from)?; - // The map installed BEFORE reads begin. Two sources: - // - A caller-supplied `key_map` (autorip's FMTS gate resolved the - // forensic per-segment map and passes it here) is used VERBATIM — - // never re-resolved. - // - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS - // disc that the caller did NOT map. Resolve the base map here off the - // live reader, exactly as the `Session` arm and `build_iso_pipeline` - // do — otherwise the AACS `DecryptingSectorSource` has no map and - // fails `DecryptFailed` on the first content unit (the single-pass - // live-mux decrypt bug). Borrow to sample, then move into the stream. - // DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in - // `DiscStream::new`; raw is ciphertext passthrough). - let base_map = match key_map { - Some(map) => Some(map), - None => resolve_inline_base_map( + // Resolve the AACS key map off the STAGED reader BEFORE it is moved + // into `DiscStream::new` (borrow to sample, then move to construct). + // Without this the AACS `DecryptingSectorSource` inside the stream has + // no map and fails `DecryptFailed` on the first content unit — the + // single-pass live-mux decrypt bug. `session.key_fetch()` (retained by + // `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is + // missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in + // `DiscStream::new`; raw is ciphertext passthrough) — unchanged. + let base_map = resolve_inline_base_map( &mut *reader, &title, &mut keys, - None, + session.key_fetch(), format, opts.raw, Some(halt), - )?, - }; - // INLINE `DiscStream` — the same constructor the `Session` arm uses, - // NOT `build_iso_pipeline` (the prefetch highway). The consumer's - // adaptive batch-retry lives in `DiscStream::fill_extents`, which the - // highway would bypass; the live single-pass path must keep it. - let mut stream = crate::mux::DiscStream::new( - reader, + )?; + let mut stream = crate::mux::DiscStream::new( + reader, + title, + keys, + opts.batch_sectors, + format, + opts.raw, + Some(halt.clone()), + )?; + if opts.raw { + stream.set_raw(); + } + if let Some(map) = base_map { + stream = stream.with_key_map(map); + } + stream.skip_errors = opts.skip_errors; + // Live path: the `DiscStream` emits the full reader-side vocabulary + // (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the + // adaptive sizer, `BytesRead` progress) — forward them all. + stream.on_event(reader_event_fn(events.clone())); + (Box::new(stream), Some(playlist), source) + } + MuxInput::Live { + mut reader, title, - keys, - opts.batch_sectors, format, - opts.raw, - Some(halt.clone()), - )?; - if opts.raw { - stream.set_raw(); + mut keys, + key_map, + } => { + // Prune to the selected streams, exactly as the Iso and Session arms + // do. Without this a caller's audio/subtitle selection was silently + // ignored on the live-drive path while the field's own doc said it was + // applied. Only touches the stream list, so the extent-keyed ciphertext + // sampling in `resolve_inline_base_map` below is unaffected. No-op for + // the default All/All. + let mut title = title; + opts.selection + .apply(&mut title) + .map_err(std::io::Error::from)?; + // Provenance: the medium is certain (a raw physical `SectorSource`), + // and the title's playlist is on hand. The device PATH and the title + // INDEX are genuinely NOT reachable: `MuxInput::Live` hands over an + // opaque `Box` with no path accessor and an + // already-scanned `DiscTitle` with no index. Both stay empty/0 — + // the sink omits an empty path rather than inventing one. + let source = SourceInfo { + medium: Medium::Disc, + playlist: title.playlist.clone(), + ..SourceInfo::default() + }; + // The map installed BEFORE reads begin. Two sources: + // - A caller-supplied `key_map` (autorip's FMTS gate resolved the + // forensic per-segment map and passes it here) is used VERBATIM — + // never re-resolved. + // - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS + // disc that the caller did NOT map. Resolve the base map here off the + // live reader, exactly as the `Session` arm and `build_iso_pipeline` + // do — otherwise the AACS `DecryptingSectorSource` has no map and + // fails `DecryptFailed` on the first content unit (the single-pass + // live-mux decrypt bug). Borrow to sample, then move into the stream. + // DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in + // `DiscStream::new`; raw is ciphertext passthrough). + let base_map = match key_map { + Some(map) => Some(map), + None => resolve_inline_base_map( + &mut *reader, + &title, + &mut keys, + None, + format, + opts.raw, + Some(halt), + )?, + }; + // INLINE `DiscStream` — the same constructor the `Session` arm uses, + // NOT `build_iso_pipeline` (the prefetch highway). The consumer's + // adaptive batch-retry lives in `DiscStream::fill_extents`, which the + // highway would bypass; the live single-pass path must keep it. + let mut stream = crate::mux::DiscStream::new( + reader, + title, + keys, + opts.batch_sectors, + format, + opts.raw, + Some(halt.clone()), + )?; + if opts.raw { + stream.set_raw(); + } + // Apply the key map BEFORE reads begin — for an FMTS forensic map this + // rewrites the extent walk to our-phase units only and installs the map + // so each unit decrypts with its mapped key; for a plain single/multi-CPS + // base map it installs the per-unit content key. `None` leaves the walk + // unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on + // this: dropping the forensic map reads the alternate device-group units + // and mis-decrypts the forensic segment. + if let Some(map) = base_map { + stream = stream.with_key_map(map); + } + stream.skip_errors = opts.skip_errors; + // Same reader-side event vocabulary as the `Session` arm + // (`SectorSkipped` / `BatchSizeChanged` / `BytesRead`). + stream.on_event(reader_event_fn(events.clone())); + (Box::new(stream), None, source) } - // Apply the key map BEFORE reads begin — for an FMTS forensic map this - // rewrites the extent walk to our-phase units only and installs the map - // so each unit decrypts with its mapped key; for a plain single/multi-CPS - // base map it installs the per-unit content key. `None` leaves the walk - // unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on - // this: dropping the forensic map reads the alternate device-group units - // and mis-decrypts the forensic segment. - if let Some(map) = base_map { - stream = stream.with_key_map(map); - } - stream.skip_errors = opts.skip_errors; - // Same reader-side event vocabulary as the `Session` arm - // (`SectorSkipped` / `BatchSizeChanged` / `BytesRead`). - stream.on_event(reader_event_fn(events.clone())); - (Box::new(stream), None) - } - }; + }; + + // The consumer-supplied disc name overrides the title's own playlist for the + // muxed title (see `drive_mux`); mirror that into the provenance so a + // `fvi://` header and its sibling MKV agree on what the playlist was called. + if let Some(name) = playlist_name.as_deref() { + source.playlist = name.to_string(); + } drive_mux( stream, @@ -495,6 +584,7 @@ pub fn mux_stream( events.as_ref(), playlist_name.as_deref(), effective_send_deadline(opts.send_deadline), + Some(&source), ) } @@ -621,6 +711,7 @@ fn drive_mux( events: &dyn MuxEvents, playlist_name: Option<&str>, send_deadline: Duration, + source: Option<&SourceInfo>, ) -> std::io::Result { // Title assembled from the scanned metadata; the playlist name (disc name) // overrides `info().playlist` where the consumer supplied one. @@ -640,7 +731,7 @@ fn drive_mux( parse_url(dest_url), StreamUrl::Chapters { .. } | StreamUrl::Json { .. } ) { - let mut sink = CountingStream::new(output(dest_url, &out_title)?); + let mut sink = CountingStream::new(output(dest_url, &out_title, source)?); events.on_output_opened(&out_title); sink.finish()?; return Ok(MuxOutcome { @@ -764,7 +855,7 @@ fn drive_mux( let num_streams = info.streams.len(); // ── Open the sink, wrap in a byte counter, hand it to the write pipeline ── - let output_stream = CountingStream::new(output(dest_url, &out_title)?); + let output_stream = CountingStream::new(output(dest_url, &out_title, source)?); events.on_output_opened(&out_title); // The write consumer runs on its own thread so the latency-bound sink write @@ -1103,6 +1194,63 @@ mod tests { (dir, url) } + /// The driver is the ONLY place that can supply a `fvi://` sink its + /// provenance — `output()` never sees the source. Pin that the + /// `SourceInfo` handed to `drive_mux` reaches the header verbatim, and + /// that the destination path is nowhere in it. + #[test] + fn drive_mux_threads_provenance_into_the_fvi_header() { + let dir = tempfile::tempdir().expect("tempdir"); + let dst = dir.path().join("index.fvi"); + let url = format!("fvi://{}", dst.display()); + let source = SourceInfo { + medium: Medium::Iso, + path: "iso://m.iso".into(), + title: 1, + playlist: "00800.mpls".into(), + volume_id: "VOL".into(), + }; + let stream = Box::new(FakeStream::new(1).with_frames(2)); + let halt = Halt::new(); + let spy = SpyEvents::new(); + drive_mux( + stream, + &url, + &halt, + &spy, + None, + Duration::from_secs(60), + Some(&source), + ) + .expect("fvi mux runs"); + + let text = std::fs::read_to_string(&dst).expect("index written"); + let hdr: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(hdr["source"]["path"], "iso://m.iso"); + assert_eq!(hdr["source"]["medium"], "iso"); + assert_eq!(hdr["source"]["title"], 1); + assert_eq!(hdr["source"]["playlist"], "00800.mpls"); + assert_eq!(hdr["source"]["volume_id"], "VOL"); + assert!( + !text.contains("index.fvi"), + "the destination path must never appear in the index it names" + ); + } + + /// The provenance medium follows the SOURCE scheme, not the sink's. A disc + /// image is `iso`, a container file is `file`, and a socket / stdio is + /// `stream` — the header used to report `file` unconditionally. + #[test] + fn url_medium_follows_the_source_scheme() { + assert_eq!(url_medium(&parse_url("iso://d.iso")), Medium::Iso); + assert_eq!(url_medium(&parse_url("disc://")), Medium::Disc); + assert_eq!(url_medium(&parse_url("mkv://m.mkv")), Medium::File); + assert_eq!(url_medium(&parse_url("m2ts://m.m2ts")), Medium::File); + assert_eq!(url_medium(&parse_url("mp4://m.mp4")), Medium::File); + assert_eq!(url_medium(&parse_url("network://h:9000")), Medium::Stream); + assert_eq!(url_medium(&parse_url("stdio://")), Medium::Stream); + } + // ── chapters:// / json:// short-circuit runs even when headers never // resolve (the bug fix). Mutation: moving the header gate before the // short-circuit makes this return Err(MkvInvalid) and the test fails. @@ -1112,8 +1260,16 @@ mod tests { let (_dir, url) = tmp("out.xml"); let halt = Halt::new(); let spy = SpyEvents::new(); - let out = drive_mux(stream, &url, &halt, &spy, None, Duration::from_secs(60)) - .expect("chapters must short-circuit"); + let out = drive_mux( + stream, + &url, + &halt, + &spy, + None, + Duration::from_secs(60), + None, + ) + .expect("chapters must short-circuit"); assert!(out.completed, "metadata sink completes without headers"); assert!(out.output_opened); assert!(spy.opened.load(Ordering::SeqCst), "sink was opened"); @@ -1133,6 +1289,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect("json must short-circuit"); assert!(out.completed); @@ -1152,6 +1309,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect_err("unresolved headers must be refused"); // This gate is the GENUINE stub case — the pump ended without any video @@ -1179,6 +1337,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect_err("empty drain must be refused"); assert_eq!(err.to_string(), format!("E{}", crate::error::E_NO_STREAMS)); @@ -1201,6 +1360,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect("halt is a clean stop, not an error"); assert!(!out.completed, "an interrupted mux is not complete"); @@ -1224,6 +1384,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect("a halt mid frame-read is a clean stop, not an Err"); assert!(!out.completed, "interrupted mux is not complete"); @@ -1247,6 +1408,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect("a halt mid header-read is a clean stop, not an Err"); assert!(!out.completed, "interrupted mux is not complete"); @@ -1269,6 +1431,7 @@ mod tests { &spy, None, Duration::from_secs(60), + None, ) .expect("normal mux completes"); assert!(out.completed); @@ -2116,6 +2279,7 @@ mod tests { &NoopEvents, None, Duration::from_secs(60), + None, ) .expect_err("over-cap header buffer must fail fast, not OOM"); // The cap overflow must carry its OWN code, not `MkvInvalid`: an @@ -2176,6 +2340,7 @@ mod tests { &events, None, Duration::from_secs(60), + None, ) .expect("K-frame stream muxes cleanly"); assert!(out.completed); diff --git a/src/mux/fvi_sink.rs b/src/mux/fvi_sink.rs index 0263e87..cef0876 100644 --- a/src/mux/fvi_sink.rs +++ b/src/mux/fvi_sink.rs @@ -155,21 +155,13 @@ impl FviSink { /// Create the sink at `path`, assembling the header from `title`'s primary /// video stream. /// - /// `source_path` / `source_title` record where the index was built from - /// (the input URL path + the 0-based title index); they are carried into the - /// header's `source` object. The remaining provenance (medium, playlist, - /// volume) takes its `SourceInfo` defaults — no caller needs to override them. - pub fn create( - path: &Path, - title: &DiscTitle, - source_path: String, - source_title: usize, - ) -> io::Result { - let source = SourceInfo { - path: source_path, - title: source_title, - ..SourceInfo::default() - }; + /// `source` records where the index was built FROM — the input medium, URL, + /// title index and (when known) playlist / volume id. It is carried verbatim + /// into the header's `source` object (`docs/FVI_FORMAT.md` §6.2), which + /// describes the INPUT, never `path` (the destination this sink writes). + /// A caller with no provenance to declare passes `SourceInfo::default()`; + /// the empty members are then omitted from the header rather than guessed. + pub fn create(path: &Path, title: &DiscTitle, source: SourceInfo) -> io::Result { let file = File::create(path)?; let video_track = title @@ -251,6 +243,7 @@ mod tests { }; use crate::mux::codec::PictureInfo; use crate::mux::codec::coding::{CodingType, Mpeg2Coding}; + use crate::mux::videomap::Medium; use crate::pes::SourcePos; use std::path::PathBuf; @@ -340,7 +333,7 @@ mod tests { fn sink_is_write_only() { let dir = tempdir(); let mut sink = - FviSink::create(&dir.join("x.fvi"), &mpeg2_title(), String::new(), 0).unwrap(); + FviSink::create(&dir.join("x.fvi"), &mpeg2_title(), SourceInfo::default()).unwrap(); let err = Stream::read(&mut sink).expect_err("read must error"); assert_eq!(err.kind(), io::ErrorKind::Unsupported); let _ = std::fs::remove_dir_all(&dir); @@ -350,7 +343,17 @@ mod tests { fn sink_writes_header_and_only_video_records() { let dir = tempdir(); let path = dir.join("movie.fvi"); - let mut sink = FviSink::create(&path, &mpeg2_title(), "iso://m.iso".into(), 1).unwrap(); + let mut sink = FviSink::create( + &path, + &mpeg2_title(), + SourceInfo { + medium: Medium::Iso, + path: "iso://m.iso".into(), + title: 1, + ..SourceInfo::default() + }, + ) + .unwrap(); // Video frame on track 0 → indexed. Offset 2148 = sector 1, byte 100 // within that sector (exercises the within-sector `src.byte`, §9). sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2148)))) @@ -371,8 +374,11 @@ mod tests { assert_eq!(header["stream"]["scan"], "interlaced"); // 480i assert_eq!(header["stream"]["codec"], "mpeg2video"); assert_eq!(header["timescale"], 1_000_000_000u64); + // The provenance is carried through verbatim: the caller's medium, not a + // default, and the caller's title index. assert_eq!(header["source"]["title"], 1); - assert_eq!(header["source"]["medium"], "file"); // default medium + assert_eq!(header["source"]["medium"], "iso"); + assert_eq!(header["source"]["path"], "iso://m.iso"); let rec: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); assert_eq!(rec["n"], 0); @@ -397,7 +403,7 @@ mod tests { fn empty_title_still_emits_valid_header() { let dir = tempdir(); let path = dir.join("empty.fvi"); - let mut sink = FviSink::create(&path, &mpeg2_title(), String::new(), 0).unwrap(); + let mut sink = FviSink::create(&path, &mpeg2_title(), SourceInfo::default()).unwrap(); sink.finish().unwrap(); // no frames let text = std::fs::read_to_string(&path).unwrap(); assert_eq!(text.lines().count(), 1, "header only"); @@ -411,7 +417,7 @@ mod tests { // Output is always JSON Lines regardless of extension (one format today). let dir = tempdir(); let path = dir.join("idx.jsonl"); - let mut sink = FviSink::create(&path, &mpeg2_title(), String::new(), 0).unwrap(); + let mut sink = FviSink::create(&path, &mpeg2_title(), SourceInfo::default()).unwrap(); sink.write(&vframe(0, Some(i_pic()), None)).unwrap(); sink.finish().unwrap(); let text = std::fs::read_to_string(&path).unwrap(); @@ -428,7 +434,16 @@ mod tests { // pts populated, and NO mpeg2-only field members. let dir = tempdir(); let path = dir.join("uhd.fvi"); - let mut sink = FviSink::create(&path, &hevc_title(), "disc://".into(), 0).unwrap(); + let mut sink = FviSink::create( + &path, + &hevc_title(), + SourceInfo { + medium: Medium::Disc, + path: "disc://".into(), + ..SourceInfo::default() + }, + ) + .unwrap(); // HEVC IDR (keyframe) with real provenance. sink.write(&vframe_kf(0, None, true, Some(SourcePos::at_byte(12288)))) .unwrap(); diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 157d8e0..630f9a1 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -14,7 +14,7 @@ //! ```text //! let mut input = input("iso://Disc.iso", &opts)?; //! let title = input.info().clone(); -//! let mut output = output("mkv://Movie.mkv", &title)?; +//! let mut output = output("mkv://Movie.mkv", &title, None)?; //! while let Ok(Some(frame)) = input.read() { //! output.write(&frame)?; //! } @@ -115,10 +115,15 @@ pub(crate) mod videomap; // `demux://` and `fvi://` sinks are constructed internally by `output()` via the // direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed, // and no consumer names these types, so they are not public API. +// +// The provenance types ARE public: `output()` takes a `SourceInfo` so a `fvi://` +// destination can record the INPUT it was built from (`docs/FVI_FORMAT.md` §6.2) +// rather than the file it is writing. pub use disc::DiscStream; pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; pub use m2ts::M2tsStream; pub use mkvstream::MkvStream; +pub use videomap::{Medium, SourceInfo}; // `Mp4Sink` is public (like `MkvStream` / `M2tsStream`) so a caller that drives // the sink directly can ask `final_report()` what the finished file actually // contains — the pre-mux `mp4_fit_report` plan is a prediction, and two of its diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 518787a..0874115 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -579,9 +579,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result, ) -> io::Result> { let parsed = parse_url(url); match parsed { @@ -686,16 +695,19 @@ pub fn output( )?)) } // `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`). - // The bare `output()` arm records the resolver path as the provenance - // `source.path` and defaults the title index to 0 (the resolver carries - // no title-index context). + // The header's `source` object describes the INPUT, so it comes from the + // caller-supplied `source` — never from `path`, which is the destination + // this sink writes. Passing the destination here made every index name + // itself as its own source AND made the output non-reproducible (two + // machines indexing identical bytes emitted different files purely from + // where they wrote). `None` → the neutral defaults; the optional members + // are omitted rather than guessed. StreamUrl::Fvi { ref path } => { validate_file_path(path, "fvi")?; Ok(Box::new(super::fvi_sink::FviSink::create( path, title, - path.to_string_lossy().into_owned(), - 0, + source.cloned().unwrap_or_default(), )?)) } // `chapters://` and `json://` write the title metadata at construction and @@ -2223,6 +2235,7 @@ fn build_m2ts_pipeline( #[cfg(test)] mod tests { + use super::super::videomap::{Medium, SourceInfo}; use super::StreamUrl; use super::parse_url; use super::validate_network_addr; @@ -2347,12 +2360,134 @@ mod tests { } } fn output_err_kind(url: &str, t: &DiscTitle) -> std::io::ErrorKind { - match output(url, t) { + match output(url, t, None) { Ok(_) => panic!("expected output({url}) to error"), Err(e) => e.kind(), } } + // ── fvi:// provenance ───────────────────────────────────────────────── + + /// Tiny unique temp dir helper (avoids a dev-dependency on `tempfile`). + fn fvi_tempdir() -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let p = std::env::temp_dir().join(format!("fmkv_resolve_fvi_{}_{}", std::process::id(), n)); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// A minimal single-video-stream title, enough for `FviSink` to build a + /// header row. + fn fvi_title() -> DiscTitle { + use crate::disc::{ + Codec, ColorSpace, FrameRate, HdrFormat, Resolution, Stream as DiscStream, VideoStream, + }; + let mut t = DiscTitle::empty(); + t.streams = vec![DiscStream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Mpeg2, + resolution: Resolution::R480i, + frame_rate: FrameRate::F29_97, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Smpte170m, + display_aspect: Some((16, 9)), + secondary: false, + label: String::new(), + measured_cicp: None, + })]; + t.content_format = ContentFormat::MpegPs; + t + } + + /// Read the header row (line 1) of an FVI file as JSON. + fn fvi_header(path: &std::path::Path) -> serde_json::Value { + let text = std::fs::read_to_string(path).unwrap(); + serde_json::from_str(text.lines().next().unwrap()).unwrap() + } + + /// `fvi://` must record the SOURCE in `source.{path,medium,title}`. It used + /// to pass the DESTINATION path as `FviSink::create`'s `source_path` (and + /// default the medium/title), so every index claimed to be its own source — + /// `docs/FVI_FORMAT.md` §6.2 defines `source` as describing the input. + #[test] + fn fvi_output_records_the_source_not_the_destination() { + let dir = fvi_tempdir(); + let dst = dir.join("out.fvi"); + let src = SourceInfo { + medium: Medium::Iso, + path: "iso://m.iso".into(), + title: 1, + ..SourceInfo::default() + }; + let mut sink = output( + &format!("fvi://{}", dst.display()), + &fvi_title(), + Some(&src), + ) + .expect("fvi sink"); + sink.finish().unwrap(); + + let hdr = fvi_header(&dst); + assert_eq!(hdr["source"]["path"], "iso://m.iso"); + assert_eq!(hdr["source"]["medium"], "iso"); + assert_eq!(hdr["source"]["title"], 1); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The property the destination-as-source defect broke: two runs indexing + /// the SAME source must produce byte-identical output regardless of where + /// they write. Previously the header embedded the destination path, so the + /// two files differed (and differed in length when the paths differed in + /// length) purely from where they landed. + #[test] + fn fvi_output_is_reproducible_across_destination_paths() { + let dir = fvi_tempdir(); + let src = SourceInfo { + medium: Medium::Iso, + path: "iso://m.iso".into(), + title: 1, + ..SourceInfo::default() + }; + // Deliberately different lengths — the parity run's byte-count delta + // tracked exactly the destination path-length difference. + let a = dir.join("a.fvi"); + let b = dir.join("a-much-longer-destination-name.fvi"); + for dst in [&a, &b] { + let mut sink = output( + &format!("fvi://{}", dst.display()), + &fvi_title(), + Some(&src), + ) + .unwrap(); + sink.finish().unwrap(); + } + assert_eq!( + std::fs::read(&a).unwrap(), + std::fs::read(&b).unwrap(), + "same source, different destinations must produce identical bytes" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// No provenance to declare (`None`) must not fabricate one: the header + /// carries the neutral `SourceInfo` defaults — an empty path, `file`, + /// title 0 — never the destination it happens to be writing to. + #[test] + fn fvi_output_without_provenance_emits_no_path() { + let dir = fvi_tempdir(); + let dst = dir.join("bare.fvi"); + let mut sink = output(&format!("fvi://{}", dst.display()), &fvi_title(), None).unwrap(); + sink.finish().unwrap(); + + let hdr = fvi_header(&dst); + assert_eq!(hdr["source"]["path"], ""); + assert_eq!(hdr["source"]["medium"], "file"); + assert_eq!(hdr["source"]["title"], 0); + let _ = std::fs::remove_dir_all(&dir); + } + /// The resolver doc table marks disc:// as input-only via the /// `Drive::open` path — input("disc://") must surface DiscUrlNotDirect /// (E9009 → Unsupported), never attempt to open a stream. diff --git a/src/mux/videomap.rs b/src/mux/videomap.rs index 3e2f2c6..b1c43b9 100644 --- a/src/mux/videomap.rs +++ b/src/mux/videomap.rs @@ -126,8 +126,9 @@ impl Scan { /// Source `medium` for the header `source.medium` member /// (`docs/FVI_FORMAT.md` §6.2). Describes the physical/logical input the index -/// was built from. The bare resolver path has no input-URL context, so it -/// defaults to [`Medium::File`]; the CLI follow-up passes the real medium. +/// was built from — never the destination the index is written to. The driver +/// derives it from the `MuxInput` arm; [`Medium::File`] is the default only for +/// a caller that declares no provenance at all. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Medium { Disc, diff --git a/src/pes.rs b/src/pes.rs index 28c34b7..e18f2a8 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -257,7 +257,7 @@ pub trait Stream: Send { /// Wrap the output with `CountingStream`, then query `bytes_written()`. /// /// ```text -/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?); +/// let mut output = CountingStream::new(libfreemkv::output(dest, &title, None)?); /// while let Ok(Some(frame)) = input.read() { /// output.write(&frame)?; /// let pct = output.bytes_written() as f64 / total as f64; diff --git a/tests/fvi_pipeline.rs b/tests/fvi_pipeline.rs index f0be62f..8b5baa0 100644 --- a/tests/fvi_pipeline.rs +++ b/tests/fvi_pipeline.rs @@ -11,7 +11,7 @@ use libfreemkv::disc::{ VideoStream, }; use libfreemkv::pes::Stream as PesStream; -use libfreemkv::{DecryptKeys, SectorSource, build_iso_pipeline, output}; +use libfreemkv::{DecryptKeys, Medium, SectorSource, SourceInfo, build_iso_pipeline, output}; use std::path::PathBuf; /// DVD video PES stream_id (0xE0). @@ -182,8 +182,16 @@ fn run_to_fvi(image: Vec, title: DiscTitle, path: &std::path::Path) { ) .expect("pipeline builds"); + // Real provenance for the run — the header must describe THIS input, not + // the `.fvi` it is writing. + let source = SourceInfo { + medium: Medium::Iso, + path: "iso://two-gop.iso".into(), + title: 2, + ..SourceInfo::default() + }; let url = format!("fvi://{}", path.display()); - let mut sink = output(&url, &title).expect("fvi sink opens"); + let mut sink = output(&url, &title, Some(&source)).expect("fvi sink opens"); while let Some(frame) = input.read().expect("read ok") { sink.write(&frame).expect("sink write ok"); @@ -217,8 +225,16 @@ fn fvi_sink_indexes_real_mpeg2_pipeline_output() { assert_eq!(stream["colour"]["transfer"], 6); assert_eq!(stream["colour"]["matrix"], 6); assert_eq!(stream["colour"]["range"], "limited"); - // Provenance root: medium defaults to "file", sector_size present. + // Provenance root describes the INPUT the index was built from — never the + // destination `.fvi` (which is what the sink used to record). assert_eq!(header["source"]["sector_size"], 2048); + assert_eq!(header["source"]["medium"], "iso"); + assert_eq!(header["source"]["path"], "iso://two-gop.iso"); + assert_eq!(header["source"]["title"], 2); + assert!( + !header["source"]["path"].as_str().unwrap().contains(".fvi"), + "the destination path must never leak into source.path" + ); // ── Records ─────────────────────────────────────────────────────────────── let records: Vec = lines.map(|l| serde_json::from_str(l).unwrap()).collect(); @@ -324,7 +340,7 @@ fn fvi_sink_indexes_non_mpeg2_frames_codec_agnostically() { }; let url = format!("fvi://{}", path.display()); - let mut sink = output(&url, &title).expect("fvi sink opens"); + let mut sink = output(&url, &title, None).expect("fvi sink opens"); // IDR (keyframe) with real provenance — must NOT be null/"?". sink.write(&mk(1234, true, Some(SourcePos::at_byte(8192)))) .unwrap(); diff --git a/tests/streams.rs b/tests/streams.rs index d9edc2f..bd629a2 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -136,7 +136,7 @@ fn open_input_bare_path_errors() { #[test] fn open_output_bare_path_errors() { let dt = sample_disc_title(); - let result = libfreemkv::output("Movie.mkv", &dt); + let result = libfreemkv::output("Movie.mkv", &dt, None); assert!(result.is_err()); let msg = match result { Err(e) => e.to_string(), @@ -182,7 +182,7 @@ fn open_output_null_input_errors() { #[test] fn open_output_disc_errors() { let dt = sample_disc_title(); - let result = libfreemkv::output("disc://", &dt); + let result = libfreemkv::output("disc://", &dt, None); assert!(result.is_err()); let msg = match result { Err(e) => e.to_string(),