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:
+343
-178
@@ -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<dyn Stream>, Option<String>) = 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<dyn Stream>, Option<String>, 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<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
|
||||
// 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<MuxOutcome> {
|
||||
// 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);
|
||||
|
||||
+36
-21
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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();
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+141
-6
@@ -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).
|
||||
///
|
||||
/// `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(
|
||||
url: &str,
|
||||
title: &crate::disc::DiscTitle,
|
||||
source: Option<&super::videomap::SourceInfo>,
|
||||
) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
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<R: std::io::Read + Send + 'static>(
|
||||
|
||||
#[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.
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user