unlock: route_unlock returns a structured UnlockRoute; ctx carries opts
Replace route_unlock's Option<(name, Vid)> with a structured UnlockRoute
{ Unlocked(name, Unlocked) | Failed(UnlockError) | NoMatch } so a single
dispatch serves every caller: drive-prep wants "did anything unlock", and the
AACS cert route (next) needs the FAILURE REASON to render "missing keys" vs
"host cert rejected" instead of collapsing it to a bare None. Only a genuine
SCSI transport fault still returns Err (abort). UnlockCtx gains an optional
ScanOptions (the cert route's host-cert source), and read_mkb_from_drive now
takes &mut dyn ScsiTransport — both prerequisites for the cert handshake to
become an external freemkv-unlock-aacs unlocker. Drive-prep + CSS callers fold
the new outcome; no behavior change.
This commit is contained in:
+5
-3
@@ -933,7 +933,9 @@ const MKB_PACK_SIZE: usize = 32772;
|
|||||||
|
|
||||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||||
/// Returns the concatenated MKB data from all packs.
|
/// Returns the concatenated MKB data from all packs.
|
||||||
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
|
pub fn read_mkb_from_drive(
|
||||||
|
session: &mut dyn crate::scsi::ScsiTransport,
|
||||||
|
) -> crate::error::Result<Vec<u8>> {
|
||||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||||
|
|
||||||
let cdb = [
|
let cdb = [
|
||||||
@@ -951,7 +953,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
|
|||||||
0x00,
|
0x00,
|
||||||
];
|
];
|
||||||
let mut buf = vec![0u8; 32772];
|
let mut buf = vec![0u8; 32772];
|
||||||
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||||
|
|
||||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||||
if data_len < 2 {
|
if data_len < 2 {
|
||||||
@@ -989,7 +991,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
|
|||||||
|
|
||||||
let mut buf = vec![0u8; 32772];
|
let mut buf = vec![0u8; 32772];
|
||||||
if session
|
if session
|
||||||
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ impl AacsCertUnlocker<'_> {
|
|||||||
// MKB generation (best-effort) — forwarded to each source's
|
// MKB generation (best-effort) — forwarded to each source's
|
||||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||||
// (the default impl ignores it). A read failure leaves it `None`.
|
// (the default impl ignores it). A read failure leaves it `None`.
|
||||||
let mkb_gen = aacs::read_mkb_from_drive(session)
|
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| aacs::mkb_version(&m));
|
.and_then(|m| aacs::mkb_version(&m));
|
||||||
|
|
||||||
|
|||||||
+22
-6
@@ -1482,12 +1482,28 @@ impl Disc {
|
|||||||
let drive_id = session.drive_id.clone();
|
let drive_id = session.drive_id.clone();
|
||||||
let css_ctx =
|
let css_ctx =
|
||||||
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css);
|
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css);
|
||||||
if let Err(e) = crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) {
|
match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) {
|
||||||
tracing::warn!(
|
Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {}
|
||||||
target: "freemkv::scan",
|
Ok(crate::unlock::UnlockRoute::Failed(e)) => {
|
||||||
error_code = e.code(),
|
tracing::warn!(
|
||||||
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
|
target: "freemkv::scan",
|
||||||
);
|
outcome = ?e,
|
||||||
|
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(crate::unlock::UnlockRoute::NoMatch) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::scan",
|
||||||
|
"no CSS unlocker registered; scrambled sectors may be unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::scan",
|
||||||
|
error_code = e.code(),
|
||||||
|
"CSS bus-auth unlock hit a transport fault; scrambled sectors may be unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Size the crack's batch reads to THIS drive's per-command max
|
// Size the crack's batch reads to THIS drive's per-command max
|
||||||
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
||||||
|
|||||||
+4
-2
@@ -418,7 +418,7 @@ impl Drive {
|
|||||||
);
|
);
|
||||||
self.init_ran = true;
|
self.init_ran = true;
|
||||||
let r = match r {
|
let r = match r {
|
||||||
Ok(Some((name, unlocked))) => {
|
Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => {
|
||||||
self.unlocker_name = Some(name);
|
self.unlocker_name = Some(name);
|
||||||
// Stash the OEM Volume ID the firmware unlocker returned for the
|
// Stash the OEM Volume ID the firmware unlocker returned for the
|
||||||
// AACS handshake phase (do_handshake reads it via `oem_vid()`).
|
// AACS handshake phase (do_handshake reads it via `oem_vid()`).
|
||||||
@@ -448,7 +448,9 @@ impl Drive {
|
|||||||
// No unlocker matched, or one matched but only hit a capability
|
// No unlocker matched, or one matched but only hit a capability
|
||||||
// failure (not firmware-unlockable / no OEM VID): not an error —
|
// failure (not firmware-unlockable / no OEM VID): not an error —
|
||||||
// fall through to the OEM host-cert route.
|
// fall through to the OEM host-cert route.
|
||||||
Ok(None) => Ok(()),
|
Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
// A genuine transport fault during unlock (UnlockError::Scsi)
|
// A genuine transport fault during unlock (UnlockError::Scsi)
|
||||||
// propagates here and aborts init — the bus is dead.
|
// propagates here and aborts init — the bus is dead.
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
|
|||||||
+98
-63
@@ -113,19 +113,52 @@ pub enum DiscKind {
|
|||||||
/// what it needs — firmware keys off [`Self::drive_id`]; cert/CSS off
|
/// what it needs — firmware keys off [`Self::drive_id`]; cert/CSS off
|
||||||
/// [`Self::kind`]. `#[non_exhaustive]` so more context (e.g. a host-cert source)
|
/// [`Self::kind`]. `#[non_exhaustive]` so more context (e.g. a host-cert source)
|
||||||
/// can be added later without breaking external unlockers.
|
/// can be added later without breaking external unlockers.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub struct UnlockCtx<'a> {
|
pub struct UnlockCtx<'a> {
|
||||||
/// Identity of the drive being unlocked.
|
/// Identity of the drive being unlocked.
|
||||||
pub drive_id: &'a DriveId,
|
pub drive_id: &'a DriveId,
|
||||||
/// Bus-encryption class of the loaded disc (`Unknown` during drive-prep).
|
/// Bus-encryption class of the loaded disc (`Unknown` during drive-prep).
|
||||||
pub kind: DiscKind,
|
pub kind: DiscKind,
|
||||||
|
/// Scan options carrying the host-cert source for the AACS cert route.
|
||||||
|
/// `None` for the drive-prep / CSS dispatches (they need no host certs).
|
||||||
|
pub opts: Option<&'a crate::disc::ScanOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual Debug: ScanOptions carries non-Debug key-source trait objects, so the
|
||||||
|
// derived impl can't see through `opts` — report only whether it's present.
|
||||||
|
impl std::fmt::Debug for UnlockCtx<'_> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("UnlockCtx")
|
||||||
|
.field("drive_id", &self.drive_id)
|
||||||
|
.field("kind", &self.kind)
|
||||||
|
.field("has_opts", &self.opts.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> UnlockCtx<'a> {
|
impl<'a> UnlockCtx<'a> {
|
||||||
/// Construct a context for the given drive and disc kind.
|
/// Construct a context for the given drive and disc kind (no host certs).
|
||||||
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
|
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
|
||||||
Self { drive_id, kind }
|
Self {
|
||||||
|
drive_id,
|
||||||
|
kind,
|
||||||
|
opts: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a context carrying scan options (the AACS cert route's
|
||||||
|
/// host-cert source).
|
||||||
|
pub fn with_opts(
|
||||||
|
drive_id: &'a DriveId,
|
||||||
|
kind: DiscKind,
|
||||||
|
opts: &'a crate::disc::ScanOptions,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
drive_id,
|
||||||
|
kind,
|
||||||
|
opts: Some(opts),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,53 +215,51 @@ fn ensure_builtins() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk the registry in order and run the first matching unlocker, returning
|
/// Outcome of one registry dispatch at a single [`UnlockCtx`]. Carries enough
|
||||||
/// its name AND the Volume ID it produced.
|
/// for every caller: the firmware/cert path wants the learned [`Unlocked`], the
|
||||||
///
|
/// cert path also wants the *reason* on failure (to render "missing keys" vs
|
||||||
/// Returns:
|
/// "host cert rejected"), and drive-prep just wants "did anything unlock".
|
||||||
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
|
#[derive(Debug)]
|
||||||
/// into extended mode, and returned the OEM Volume ID. The caller stashes
|
pub(crate) enum UnlockRoute {
|
||||||
/// the VID for the handshake phase and need not run the cert handshake.
|
/// A matching unlocker removed the barrier; carries its name + learned data.
|
||||||
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a
|
Unlocked(String, Unlocked),
|
||||||
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`],
|
/// A matching unlocker reported a capability failure — it does not apply,
|
||||||
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged).
|
/// the disc is not its kind, or auth was rejected. NOT a transport fault.
|
||||||
/// Either way the drive is usable in stock mode and the caller falls
|
/// The caller renders the reason or falls through to the next phase. (The
|
||||||
/// through to the in-tree cert handshake. Folding a capability failure into
|
/// unlocker's name is already logged by `route_unlock`.)
|
||||||
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't
|
Failed(UnlockError),
|
||||||
/// firmware-unlockable must not fail init.
|
/// No registered unlocker matched this context.
|
||||||
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault
|
NoMatch,
|
||||||
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so
|
}
|
||||||
/// this propagates and aborts init rather than silently falling through to
|
|
||||||
/// a cert handshake that would also fail.
|
/// Walk the registry in registration order and run the FIRST unlocker whose
|
||||||
pub(crate) fn route_unlock(
|
/// [`Unlocker::matches`] is true for `ctx`, returning a structured
|
||||||
scsi: &mut dyn ScsiTransport,
|
/// [`UnlockRoute`]. Only a genuine SCSI/transport fault
|
||||||
ctx: &UnlockCtx,
|
/// ([`UnlockError::Scsi`]) returns `Err` — the bus is broken, so the caller
|
||||||
) -> Result<Option<(String, Unlocked)>> {
|
/// must abort rather than silently fall through; everything else (capability
|
||||||
|
/// failure, no match) is an `Ok(UnlockRoute::…)` the caller folds.
|
||||||
|
pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx) -> Result<UnlockRoute> {
|
||||||
ensure_builtins();
|
ensure_builtins();
|
||||||
let reg = match REGISTRY.read() {
|
let reg = match REGISTRY.read() {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
// A poisoned lock means a prior unlocker panicked; treat as
|
// A poisoned lock means a prior unlocker panicked; treat as
|
||||||
// "no unlocker available" so the cert fallback still runs.
|
// "no unlocker available" so the cert fallback still runs.
|
||||||
Err(_) => return Ok(None),
|
Err(_) => return Ok(UnlockRoute::NoMatch),
|
||||||
};
|
};
|
||||||
// Walk in registration order — the registry is the single ordered place
|
// Walk in registration order — the registry is the single ordered place
|
||||||
// that decides which unlocker runs first (register ld, then aacs, then css).
|
// that decides which unlocker runs first (register ld, then aacs, then css).
|
||||||
for u in reg.iter() {
|
for u in reg.iter() {
|
||||||
if u.matches(ctx) {
|
if u.matches(ctx) {
|
||||||
let name = u.name().to_string();
|
let name = u.name().to_string();
|
||||||
match u.unlock(scsi, ctx) {
|
return match u.unlock(scsi, ctx) {
|
||||||
// A successful unlock removed the barrier — return what it
|
// A successful unlock removed the barrier — return what it
|
||||||
// learned (VID and/or bus key, plus drive_unlocked) verbatim;
|
// learned (VID and/or bus key, plus drive_unlocked) verbatim;
|
||||||
// libfreemkv files those onto the disc/drive. A firmware route
|
// libfreemkv files those onto the disc/drive.
|
||||||
// carries a VID with drive_unlocked=true; the cert route a VID +
|
Ok(unlocked) => Ok(UnlockRoute::Unlocked(name, unlocked)),
|
||||||
// read_data_key; CSS an empty Unlocked (side-effect only).
|
// A genuine SCSI/transport fault is not "this disc can't be
|
||||||
Ok(unlocked) => return Ok(Some((name, unlocked))),
|
// unlocked" — the bus is broken. Propagate so the caller aborts
|
||||||
// A genuine SCSI/transport fault is not "this drive can't be
|
// instead of falling through to another route that will also
|
||||||
// unlocked" — the bus is broken. Propagate so init() aborts
|
// fail on the same dead transport.
|
||||||
// instead of falling through to a cert handshake that will
|
|
||||||
// also fail on the same dead transport. The numeric code from
|
|
||||||
// the originating error is logged; the returned error is the
|
|
||||||
// canonical transport-error variant.
|
|
||||||
Err(UnlockError::Scsi(code)) => {
|
Err(UnlockError::Scsi(code)) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: "freemkv::unlock",
|
target: "freemkv::unlock",
|
||||||
@@ -236,28 +267,28 @@ pub(crate) fn route_unlock(
|
|||||||
code,
|
code,
|
||||||
"unlocker hit a transport fault during unlock; aborting"
|
"unlocker hit a transport fault during unlock; aborting"
|
||||||
);
|
);
|
||||||
return Err(crate::error::Error::ScsiError {
|
Err(crate::error::Error::ScsiError {
|
||||||
opcode: 0,
|
opcode: 0,
|
||||||
status: 0,
|
status: 0,
|
||||||
sense: None,
|
sense: None,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
// A firmware unlocker that can't unlock / has no OEM VID:
|
// A capability failure (not firmware-unlockable, NotApplicable,
|
||||||
// fall through to the cert handshake. Debug-only structured
|
// cert rejected, …). Carry the reason so the caller can render
|
||||||
// log (variant identifiers, no English prose).
|
// it; drive-prep simply falls through.
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
target: "freemkv::unlock",
|
target: "freemkv::unlock",
|
||||||
unlocker = %name,
|
unlocker = %name,
|
||||||
outcome = ?e,
|
outcome = ?e,
|
||||||
"unlocker matched but produced no VID; falling through to cert handshake"
|
"unlocker matched but did not unlock; caller folds the reason"
|
||||||
);
|
);
|
||||||
return Ok(None);
|
Ok(UnlockRoute::Failed(e))
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(UnlockRoute::NoMatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk the registry in order and ask the first matching unlocker to raise
|
/// Walk the registry in order and ask the first matching unlocker to raise
|
||||||
@@ -449,21 +480,23 @@ mod tests {
|
|||||||
&UnlockCtx::new(&fake_id("MATCHVND"), DiscKind::Unknown),
|
&UnlockCtx::new(&fake_id("MATCHVND"), DiscKind::Unknown),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert!(
|
||||||
matched.as_ref().map(|(n, _)| n.as_str()),
|
matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
|
||||||
Some("fake"),
|
|
||||||
"matching unlocker runs"
|
"matching unlocker runs"
|
||||||
);
|
);
|
||||||
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
|
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
|
||||||
|
|
||||||
// Non-matching identity → no unlocker runs, cert path (None).
|
// Non-matching identity → no unlocker runs, cert path (NoMatch).
|
||||||
ran.store(false, Ordering::SeqCst);
|
ran.store(false, Ordering::SeqCst);
|
||||||
let none = route_unlock(
|
let none = route_unlock(
|
||||||
&mut scsi,
|
&mut scsi,
|
||||||
&UnlockCtx::new(&fake_id("OTHERVND"), DiscKind::Unknown),
|
&UnlockCtx::new(&fake_id("OTHERVND"), DiscKind::Unknown),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(none.is_none(), "no match → cert fallback");
|
assert!(
|
||||||
|
matches!(none, UnlockRoute::NoMatch),
|
||||||
|
"no match → cert fallback"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!ran.load(Ordering::SeqCst),
|
!ran.load(Ordering::SeqCst),
|
||||||
"unlock() not invoked on no-match"
|
"unlock() not invoked on no-match"
|
||||||
@@ -494,13 +527,13 @@ mod tests {
|
|||||||
&UnlockCtx::new(&fake_id("VIDVNDOR"), DiscKind::Unknown),
|
&UnlockCtx::new(&fake_id("VIDVNDOR"), DiscKind::Unknown),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert!(
|
||||||
got.and_then(|(_, u)| u.vid),
|
matches!(&got, UnlockRoute::Unlocked(_, u) if u.vid == Some(Vid(vid))),
|
||||||
Some(Vid(vid)),
|
|
||||||
"matching unlocker's OEM VID is used"
|
"matching unlocker's OEM VID is used"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert.
|
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → a
|
||||||
|
// capability failure carrying the reason, NOT a transport fault.
|
||||||
register_unlocker(Box::new(
|
register_unlocker(Box::new(
|
||||||
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
|
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
|
||||||
));
|
));
|
||||||
@@ -510,17 +543,20 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
got.is_none(),
|
matches!(got, UnlockRoute::Failed(UnlockError::VidUnavailable)),
|
||||||
"unlocker without OEM VID falls through to cert"
|
"unlocker without OEM VID is a capability failure → cert fallback"
|
||||||
);
|
);
|
||||||
|
|
||||||
// No matching unlocker → Ok(None), cert fallback.
|
// No matching unlocker → NoMatch, cert fallback.
|
||||||
let got = route_unlock(
|
let got = route_unlock(
|
||||||
&mut scsi,
|
&mut scsi,
|
||||||
&UnlockCtx::new(&fake_id("UNKNWNVD"), DiscKind::Unknown),
|
&UnlockCtx::new(&fake_id("UNKNWNVD"), DiscKind::Unknown),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(got.is_none(), "no match → cert fallback");
|
assert!(
|
||||||
|
matches!(got, UnlockRoute::NoMatch),
|
||||||
|
"no match → cert fallback"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A matching unlocker that hits a genuine transport fault
|
/// A matching unlocker that hits a genuine transport fault
|
||||||
@@ -657,9 +693,8 @@ mod tests {
|
|||||||
&UnlockCtx::new(&fake_id("DUPEVNDR"), DiscKind::Unknown),
|
&UnlockCtx::new(&fake_id("DUPEVNDR"), DiscKind::Unknown),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert!(
|
||||||
matched.as_ref().map(|(n, _)| n.as_str()),
|
matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
|
||||||
Some("fake"),
|
|
||||||
"a match was routed"
|
"a match was routed"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in New Issue
Block a user