Record the source, not the destination, in the FVI header

The `fvi://` arm of `output()` passed the destination `.fvi` path as
`FviSink::create`'s `source_path`, so every index named itself as its
own source. `SourceInfo::default()` supplied the rest, making
`source.medium` always "file" and `source.title` always 0 — three
header members wrong, where FVI_FORMAT.md §6.2 defines `source` as
describing the input.

Beyond the wrong data, it made the output unreproducible: two machines
indexing identical bytes emitted different files purely from where
they wrote them, and a local filesystem path leaked into a shareable
file.

`output()` cannot see the source, so thread the provenance down from
the driver, which can: `mux_stream` derives a `SourceInfo` per
`MuxInput` arm and passes it through `drive_mux` to `output()`. Per the
one-method-per-action rule this is a signature change, not an
`output_with_source()` variant; the parameter is `Option<&SourceInfo>`
so a caller with no provenance declares none rather than back-filling
the destination. `SourceInfo`/`Medium` become public API.

What each arm can honestly reach:

- Session: everything — device path, the caller's title index, the
  title's playlist, the scanned volume id.
- Url: the source URL, its scheme's medium, `title_index`, and the
  playlist off the opened stream's scanned title.
- Iso: the image path and playlist. The title index is not in
  `MuxInput::Iso` (it carries a scanned `DiscTitle`, which has no
  index), so it stays 0.
- Live: medium and playlist. The reader is an opaque
  `Box<dyn SectorSource>` with no path, and again no title index.

Unreachable members are left empty rather than guessed — the sink
already omits the empty ones.
This commit is contained in:
Matthew Jackson
2026-08-02 11:19:54 -07:00
parent e008e71a17
commit cf7ee69fd5
9 changed files with 554 additions and 216 deletions
+2 -1
View File
@@ -32,7 +32,7 @@
//! let opts = libfreemkv::InputOptions::default(); //! let opts = libfreemkv::InputOptions::default();
//! let mut input = libfreemkv::input("iso://disc.iso", &opts)?; //! let mut input = libfreemkv::input("iso://disc.iso", &opts)?;
//! let title = input.info().clone(); //! 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. //! // Propagate read errors instead of silently stopping on the first one.
//! while let Some(frame) = input.read()? { //! while let Some(frame) = input.read()? {
//! output.write(&frame)?; //! output.write(&frame)?;
@@ -260,6 +260,7 @@ pub use mux::NullStream;
pub use mux::StdioStream; pub use mux::StdioStream;
pub use mux::WriteSeek; pub use mux::WriteSeek;
pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
pub use mux::{Medium, SourceInfo};
pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report}; pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report};
// ─── Lower-level surfaces ─────────────────────────────────────────────────── // ─── Lower-level surfaces ───────────────────────────────────────────────────
+343 -178
View File
@@ -41,6 +41,24 @@ use crate::session::DiscSession;
use super::resolve::{ use super::resolve::{
InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map, 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 /// Effectively-unbounded per-frame send deadline used when a consumer passes
/// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network /// `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 // into one (`reader_event_fn`) so the highway's producer thread — and the
// live `DiscStream`'s read loop — forward `BytesRead`/`SectorSkipped`/ // live `DiscStream`'s read loop — forward `BytesRead`/`SectorSkipped`/
// `BatchSizeChanged`/`ReadError` back to the consumer's handle. // `BatchSizeChanged`/`ReadError` back to the consumer's handle.
let (stream, playlist_name): (Box<dyn Stream>, Option<String>) = match input_src { //
// The Url path builds its demux INSIDE `input()`, which prunes to the // Each arm also derives the run's PROVENANCE (`SourceInfo`) — the facts a
// selected streams via `InputOptions.selection` (which the caller sets). // `fvi://` destination records in its header (`docs/FVI_FORMAT.md` §6.2).
// Note: `MuxOptions.selection` does NOT apply here — that's the File/ // `output()` has no access to the source, so the driver is the only place
// Session arms' field; a Url-source caller must set `InputOptions.selection`. // that can supply it honestly; a member the arm genuinely cannot reach is
MuxInput::Url { url, opts: in_opts } => (input(url, &in_opts)?, None), // left at its default rather than guessed (the sink omits the empty ones).
MuxInput::Iso { let (stream, playlist_name, mut source): (Box<dyn Stream>, Option<String>, SourceInfo) =
path, match input_src {
title, // The Url path builds its demux INSIDE `input()`, which prunes to the
format, // selected streams via `InputOptions.selection` (which the caller sets).
keys, // Note: `MuxOptions.selection` does NOT apply here — that's the File/
key_fetch, // Session arms' field; a Url-source caller must set `InputOptions.selection`.
} => { MuxInput::Url { url, opts: in_opts } => {
// Prune to the selected audio/subtitle streams BEFORE the highway // Provenance: the source URL verbatim, its scheme's medium, and the
// builds its demux state from `title.streams` (and before // title `input()` will open (`None` selects title 0, per its doc).
// `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3 // `playlist` is filled in below from the opened stream's scanned
// PIDs). Video is always kept; a no-op for the default All/All. // title — it is not known until the scan runs.
let mut title = title; let source = SourceInfo {
opts.selection medium: url_medium(&parse_url(url)),
.apply(&mut title) path: url.to_string(),
.map_err(std::io::Error::from)?; title: in_opts.title_index.unwrap_or(0),
let reader = FileSectorSource::open(path)?; ..SourceInfo::default()
let stream = build_iso_pipeline( };
reader, let stream = input(url, &in_opts)?;
let source = SourceInfo {
playlist: stream.info().playlist.clone(),
..source
};
(stream, None, source)
}
MuxInput::Iso {
path,
title, title,
keys,
opts.batch_sectors,
format, format,
opts.raw, keys,
Some(halt.clone()),
Some(reader_event_fn(events.clone())),
key_fetch, key_fetch,
)?; } => {
(Box::new(stream), None) // Prune to the selected audio/subtitle streams BEFORE the highway
} // builds its demux state from `title.streams` (and before
MuxInput::Session { // `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3
session, // PIDs). Video is always kept; a no-op for the default All/All.
title_index, let mut title = title;
} => { opts.selection
// Pull everything we need out of the disc as owned values so the .apply(&mut title)
// immutable disc borrow is released before the mutable .map_err(std::io::Error::from)?;
// `take_reader` below. // Provenance: the staged image, in the same `iso://` URL form the
let (mut title, format, mut keys, playlist) = { // `Url` arm records, plus the title's playlist. The 0-based TITLE
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady { // 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(), path: session.device_path().to_string(),
})?; })?;
let title = disc // Resolve the AACS key map off the STAGED reader BEFORE it is moved
.titles // into `DiscStream::new` (borrow to sample, then move to construct).
.get(title_index) // Without this the AACS `DecryptingSectorSource` inside the stream has
.cloned() // no map and fails `DecryptFailed` on the first content unit — the
.ok_or(Error::MuxTrackRange { // single-pass live-mux decrypt bug. `session.key_fetch()` (retained by
track: title_index, // `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is
tracks: disc.titles.len(), // missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in
})?; // `DiscStream::new`; raw is ciphertext passthrough) — unchanged.
let playlist = disc let base_map = resolve_inline_base_map(
.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(
&mut *reader, &mut *reader,
&title, &title,
&mut keys, &mut keys,
None, session.key_fetch(),
format, format,
opts.raw, opts.raw,
Some(halt), Some(halt),
)?, )?;
}; let mut stream = crate::mux::DiscStream::new(
// INLINE `DiscStream` — the same constructor the `Session` arm uses, reader,
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's title,
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the keys,
// highway would bypass; the live single-pass path must keep it. opts.batch_sectors,
let mut stream = crate::mux::DiscStream::new( format,
reader, 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, title,
keys,
opts.batch_sectors,
format, format,
opts.raw, mut keys,
Some(halt.clone()), key_map,
)?; } => {
if opts.raw { // Prune to the selected streams, exactly as the Iso and Session arms
stream.set_raw(); // 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<dyn SectorSource>` 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 // The consumer-supplied disc name overrides the title's own playlist for the
// base map it installs the per-unit content key. `None` leaves the walk // muxed title (see `drive_mux`); mirror that into the provenance so a
// unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on // `fvi://` header and its sibling MKV agree on what the playlist was called.
// this: dropping the forensic map reads the alternate device-group units if let Some(name) = playlist_name.as_deref() {
// and mis-decrypts the forensic segment. source.playlist = name.to_string();
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)
}
};
drive_mux( drive_mux(
stream, stream,
@@ -495,6 +584,7 @@ pub fn mux_stream(
events.as_ref(), events.as_ref(),
playlist_name.as_deref(), playlist_name.as_deref(),
effective_send_deadline(opts.send_deadline), effective_send_deadline(opts.send_deadline),
Some(&source),
) )
} }
@@ -621,6 +711,7 @@ fn drive_mux(
events: &dyn MuxEvents, events: &dyn MuxEvents,
playlist_name: Option<&str>, playlist_name: Option<&str>,
send_deadline: Duration, send_deadline: Duration,
source: Option<&SourceInfo>,
) -> std::io::Result<MuxOutcome> { ) -> std::io::Result<MuxOutcome> {
// Title assembled from the scanned metadata; the playlist name (disc name) // Title assembled from the scanned metadata; the playlist name (disc name)
// overrides `info().playlist` where the consumer supplied one. // overrides `info().playlist` where the consumer supplied one.
@@ -640,7 +731,7 @@ fn drive_mux(
parse_url(dest_url), parse_url(dest_url),
StreamUrl::Chapters { .. } | StreamUrl::Json { .. } 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); events.on_output_opened(&out_title);
sink.finish()?; sink.finish()?;
return Ok(MuxOutcome { return Ok(MuxOutcome {
@@ -764,7 +855,7 @@ fn drive_mux(
let num_streams = info.streams.len(); let num_streams = info.streams.len();
// ── Open the sink, wrap in a byte counter, hand it to the write pipeline ── // ── 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); events.on_output_opened(&out_title);
// The write consumer runs on its own thread so the latency-bound sink write // The write consumer runs on its own thread so the latency-bound sink write
@@ -1103,6 +1194,63 @@ mod tests {
(dir, url) (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 // ── chapters:// / json:// short-circuit runs even when headers never
// resolve (the bug fix). Mutation: moving the header gate before the // resolve (the bug fix). Mutation: moving the header gate before the
// short-circuit makes this return Err(MkvInvalid) and the test fails. // short-circuit makes this return Err(MkvInvalid) and the test fails.
@@ -1112,8 +1260,16 @@ mod tests {
let (_dir, url) = tmp("out.xml"); let (_dir, url) = tmp("out.xml");
let halt = Halt::new(); let halt = Halt::new();
let spy = SpyEvents::new(); let spy = SpyEvents::new();
let out = drive_mux(stream, &url, &halt, &spy, None, Duration::from_secs(60)) let out = drive_mux(
.expect("chapters must short-circuit"); 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.completed, "metadata sink completes without headers");
assert!(out.output_opened); assert!(out.output_opened);
assert!(spy.opened.load(Ordering::SeqCst), "sink was opened"); assert!(spy.opened.load(Ordering::SeqCst), "sink was opened");
@@ -1133,6 +1289,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("json must short-circuit"); .expect("json must short-circuit");
assert!(out.completed); assert!(out.completed);
@@ -1152,6 +1309,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect_err("unresolved headers must be refused"); .expect_err("unresolved headers must be refused");
// This gate is the GENUINE stub case — the pump ended without any video // This gate is the GENUINE stub case — the pump ended without any video
@@ -1179,6 +1337,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect_err("empty drain must be refused"); .expect_err("empty drain must be refused");
assert_eq!(err.to_string(), format!("E{}", crate::error::E_NO_STREAMS)); assert_eq!(err.to_string(), format!("E{}", crate::error::E_NO_STREAMS));
@@ -1201,6 +1360,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("halt is a clean stop, not an error"); .expect("halt is a clean stop, not an error");
assert!(!out.completed, "an interrupted mux is not complete"); assert!(!out.completed, "an interrupted mux is not complete");
@@ -1224,6 +1384,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("a halt mid frame-read is a clean stop, not an Err"); .expect("a halt mid frame-read is a clean stop, not an Err");
assert!(!out.completed, "interrupted mux is not complete"); assert!(!out.completed, "interrupted mux is not complete");
@@ -1247,6 +1408,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("a halt mid header-read is a clean stop, not an Err"); .expect("a halt mid header-read is a clean stop, not an Err");
assert!(!out.completed, "interrupted mux is not complete"); assert!(!out.completed, "interrupted mux is not complete");
@@ -1269,6 +1431,7 @@ mod tests {
&spy, &spy,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("normal mux completes"); .expect("normal mux completes");
assert!(out.completed); assert!(out.completed);
@@ -2116,6 +2279,7 @@ mod tests {
&NoopEvents, &NoopEvents,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect_err("over-cap header buffer must fail fast, not OOM"); .expect_err("over-cap header buffer must fail fast, not OOM");
// The cap overflow must carry its OWN code, not `MkvInvalid`: an // The cap overflow must carry its OWN code, not `MkvInvalid`: an
@@ -2176,6 +2340,7 @@ mod tests {
&events, &events,
None, None,
Duration::from_secs(60), Duration::from_secs(60),
None,
) )
.expect("K-frame stream muxes cleanly"); .expect("K-frame stream muxes cleanly");
assert!(out.completed); assert!(out.completed);
+36 -21
View File
@@ -155,21 +155,13 @@ impl FviSink {
/// Create the sink at `path`, assembling the header from `title`'s primary /// Create the sink at `path`, assembling the header from `title`'s primary
/// video stream. /// video stream.
/// ///
/// `source_path` / `source_title` record where the index was built from /// `source` records where the index was built FROM — the input medium, URL,
/// (the input URL path + the 0-based title index); they are carried into the /// title index and (when known) playlist / volume id. It is carried verbatim
/// header's `source` object. The remaining provenance (medium, playlist, /// into the header's `source` object (`docs/FVI_FORMAT.md` §6.2), which
/// volume) takes its `SourceInfo` defaults — no caller needs to override them. /// describes the INPUT, never `path` (the destination this sink writes).
pub fn create( /// A caller with no provenance to declare passes `SourceInfo::default()`;
path: &Path, /// the empty members are then omitted from the header rather than guessed.
title: &DiscTitle, pub fn create(path: &Path, title: &DiscTitle, source: SourceInfo) -> io::Result<Self> {
source_path: String,
source_title: usize,
) -> io::Result<Self> {
let source = SourceInfo {
path: source_path,
title: source_title,
..SourceInfo::default()
};
let file = File::create(path)?; let file = File::create(path)?;
let video_track = title let video_track = title
@@ -251,6 +243,7 @@ mod tests {
}; };
use crate::mux::codec::PictureInfo; use crate::mux::codec::PictureInfo;
use crate::mux::codec::coding::{CodingType, Mpeg2Coding}; use crate::mux::codec::coding::{CodingType, Mpeg2Coding};
use crate::mux::videomap::Medium;
use crate::pes::SourcePos; use crate::pes::SourcePos;
use std::path::PathBuf; use std::path::PathBuf;
@@ -340,7 +333,7 @@ mod tests {
fn sink_is_write_only() { fn sink_is_write_only() {
let dir = tempdir(); let dir = tempdir();
let mut sink = 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"); let err = Stream::read(&mut sink).expect_err("read must error");
assert_eq!(err.kind(), io::ErrorKind::Unsupported); assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
@@ -350,7 +343,17 @@ mod tests {
fn sink_writes_header_and_only_video_records() { fn sink_writes_header_and_only_video_records() {
let dir = tempdir(); let dir = tempdir();
let path = dir.join("movie.fvi"); 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 // Video frame on track 0 → indexed. Offset 2148 = sector 1, byte 100
// within that sector (exercises the within-sector `src.byte`, §9). // within that sector (exercises the within-sector `src.byte`, §9).
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2148)))) 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"]["scan"], "interlaced"); // 480i
assert_eq!(header["stream"]["codec"], "mpeg2video"); assert_eq!(header["stream"]["codec"], "mpeg2video");
assert_eq!(header["timescale"], 1_000_000_000u64); 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"]["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(); let rec: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(rec["n"], 0); assert_eq!(rec["n"], 0);
@@ -397,7 +403,7 @@ mod tests {
fn empty_title_still_emits_valid_header() { fn empty_title_still_emits_valid_header() {
let dir = tempdir(); let dir = tempdir();
let path = dir.join("empty.fvi"); 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 sink.finish().unwrap(); // no frames
let text = std::fs::read_to_string(&path).unwrap(); let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 1, "header only"); 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). // Output is always JSON Lines regardless of extension (one format today).
let dir = tempdir(); let dir = tempdir();
let path = dir.join("idx.jsonl"); 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.write(&vframe(0, Some(i_pic()), None)).unwrap();
sink.finish().unwrap(); sink.finish().unwrap();
let text = std::fs::read_to_string(&path).unwrap(); let text = std::fs::read_to_string(&path).unwrap();
@@ -428,7 +434,16 @@ mod tests {
// pts populated, and NO mpeg2-only field members. // pts populated, and NO mpeg2-only field members.
let dir = tempdir(); let dir = tempdir();
let path = dir.join("uhd.fvi"); 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. // HEVC IDR (keyframe) with real provenance.
sink.write(&vframe_kf(0, None, true, Some(SourcePos::at_byte(12288)))) sink.write(&vframe_kf(0, None, true, Some(SourcePos::at_byte(12288))))
.unwrap(); .unwrap();
+6 -1
View File
@@ -14,7 +14,7 @@
//! ```text //! ```text
//! let mut input = input("iso://Disc.iso", &opts)?; //! let mut input = input("iso://Disc.iso", &opts)?;
//! let title = input.info().clone(); //! 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() { //! while let Ok(Some(frame)) = input.read() {
//! output.write(&frame)?; //! output.write(&frame)?;
//! } //! }
@@ -115,10 +115,15 @@ pub(crate) mod videomap;
// `demux://` and `fvi://` sinks are constructed internally by `output()` via the // `demux://` and `fvi://` sinks are constructed internally by `output()` via the
// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed, // direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed,
// and no consumer names these types, so they are not public API. // 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 disc::DiscStream;
pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
pub use m2ts::M2tsStream; pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream; pub use mkvstream::MkvStream;
pub use videomap::{Medium, SourceInfo};
// `Mp4Sink` is public (like `MkvStream` / `M2tsStream`) so a caller that drives // `Mp4Sink` is public (like `MkvStream` / `M2tsStream`) so a caller that drives
// the sink directly can ask `final_report()` what the finished file actually // 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 // contains — the pre-mux `mp4_fit_report` plan is a prediction, and two of its
+141 -6
View File
@@ -579,9 +579,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
} }
/// Open a PES output stream (consumes PES frames). /// Open a PES output stream (consumes PES frames).
///
/// `source` is the provenance of the material being written — the INPUT the
/// caller is muxing from, not `url`. Only the `fvi://` sink consumes it (it
/// records the input in the index header, `docs/FVI_FORMAT.md` §6.2); every
/// other sink ignores it. `None` means "no provenance to declare": the header's
/// `source` members then carry their neutral defaults and the optional ones are
/// omitted, rather than being back-filled with the destination path — which is
/// exactly the bug this parameter exists to prevent.
pub fn output( pub fn output(
url: &str, url: &str,
title: &crate::disc::DiscTitle, title: &crate::disc::DiscTitle,
source: Option<&super::videomap::SourceInfo>,
) -> io::Result<Box<dyn crate::pes::Stream>> { ) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url); let parsed = parse_url(url);
match parsed { match parsed {
@@ -686,16 +695,19 @@ pub fn output(
)?)) )?))
} }
// `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`). // `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`).
// The bare `output()` arm records the resolver path as the provenance // The header's `source` object describes the INPUT, so it comes from the
// `source.path` and defaults the title index to 0 (the resolver carries // caller-supplied `source` — never from `path`, which is the destination
// no title-index context). // 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 } => { StreamUrl::Fvi { ref path } => {
validate_file_path(path, "fvi")?; validate_file_path(path, "fvi")?;
Ok(Box::new(super::fvi_sink::FviSink::create( Ok(Box::new(super::fvi_sink::FviSink::create(
path, path,
title, title,
path.to_string_lossy().into_owned(), source.cloned().unwrap_or_default(),
0,
)?)) )?))
} }
// `chapters://` and `json://` write the title metadata at construction and // `chapters://` and `json://` write the title metadata at construction and
@@ -2223,6 +2235,7 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::videomap::{Medium, SourceInfo};
use super::StreamUrl; use super::StreamUrl;
use super::parse_url; use super::parse_url;
use super::validate_network_addr; use super::validate_network_addr;
@@ -2347,12 +2360,134 @@ mod tests {
} }
} }
fn output_err_kind(url: &str, t: &DiscTitle) -> std::io::ErrorKind { 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"), Ok(_) => panic!("expected output({url}) to error"),
Err(e) => e.kind(), 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 /// The resolver doc table marks disc:// as input-only via the
/// `Drive::open` path — input("disc://") must surface DiscUrlNotDirect /// `Drive::open` path — input("disc://") must surface DiscUrlNotDirect
/// (E9009 → Unsupported), never attempt to open a stream. /// (E9009 → Unsupported), never attempt to open a stream.
+3 -2
View File
@@ -126,8 +126,9 @@ impl Scan {
/// Source `medium` for the header `source.medium` member /// Source `medium` for the header `source.medium` member
/// (`docs/FVI_FORMAT.md` §6.2). Describes the physical/logical input the index /// (`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 /// was built from — never the destination the index is written to. The driver
/// defaults to [`Medium::File`]; the CLI follow-up passes the real medium. /// 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Medium { pub enum Medium {
Disc, Disc,
+1 -1
View File
@@ -257,7 +257,7 @@ pub trait Stream: Send {
/// Wrap the output with `CountingStream`, then query `bytes_written()`. /// Wrap the output with `CountingStream`, then query `bytes_written()`.
/// ///
/// ```text /// ```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() { /// while let Ok(Some(frame)) = input.read() {
/// output.write(&frame)?; /// output.write(&frame)?;
/// let pct = output.bytes_written() as f64 / total as f64; /// let pct = output.bytes_written() as f64 / total as f64;
+20 -4
View File
@@ -11,7 +11,7 @@ use libfreemkv::disc::{
VideoStream, VideoStream,
}; };
use libfreemkv::pes::Stream as PesStream; 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; use std::path::PathBuf;
/// DVD video PES stream_id (0xE0). /// DVD video PES stream_id (0xE0).
@@ -182,8 +182,16 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
) )
.expect("pipeline builds"); .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 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") { while let Some(frame) = input.read().expect("read ok") {
sink.write(&frame).expect("sink write 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"]["transfer"], 6);
assert_eq!(stream["colour"]["matrix"], 6); assert_eq!(stream["colour"]["matrix"], 6);
assert_eq!(stream["colour"]["range"], "limited"); 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"]["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 ─────────────────────────────────────────────────────────────── // ── Records ───────────────────────────────────────────────────────────────
let records: Vec<serde_json::Value> = lines.map(|l| serde_json::from_str(l).unwrap()).collect(); let records: Vec<serde_json::Value> = 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 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/"?". // IDR (keyframe) with real provenance — must NOT be null/"?".
sink.write(&mk(1234, true, Some(SourcePos::at_byte(8192)))) sink.write(&mk(1234, true, Some(SourcePos::at_byte(8192))))
.unwrap(); .unwrap();
+2 -2
View File
@@ -136,7 +136,7 @@ fn open_input_bare_path_errors() {
#[test] #[test]
fn open_output_bare_path_errors() { fn open_output_bare_path_errors() {
let dt = sample_disc_title(); let dt = sample_disc_title();
let result = libfreemkv::output("Movie.mkv", &dt); let result = libfreemkv::output("Movie.mkv", &dt, None);
assert!(result.is_err()); assert!(result.is_err());
let msg = match result { let msg = match result {
Err(e) => e.to_string(), Err(e) => e.to_string(),
@@ -182,7 +182,7 @@ fn open_output_null_input_errors() {
#[test] #[test]
fn open_output_disc_errors() { fn open_output_disc_errors() {
let dt = sample_disc_title(); let dt = sample_disc_title();
let result = libfreemkv::output("disc://", &dt); let result = libfreemkv::output("disc://", &dt, None);
assert!(result.is_err()); assert!(result.is_err());
let msg = match result { let msg = match result {
Err(e) => e.to_string(), Err(e) => e.to_string(),