mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
keysources: sources report VID use, sample dependence and transport failures
keydb and MultiSource say whether an answer depends on samples; online failures are classed as transport or not without erasing the reachability slot; OnlineSource and MultiSource report uses_vid, and partial stored keys plus a Km still report NoVid. Factory builds get a DNS-free key-service URL check. Tests decrypt through libfreemkv's test_util.
This commit is contained in:
Generated
+68
-1
@@ -31,6 +31,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
@@ -240,6 +246,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
@@ -497,13 +513,22 @@ dependencies = [
|
||||
"memchr",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
@@ -677,7 +702,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -686,6 +711,19 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
@@ -804,6 +842,16 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -1021,6 +1069,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -1030,6 +1084,15 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -1242,3 +1305,7 @@ version = "1.7.7"
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.7.7"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.7"
|
||||
|
||||
@@ -34,6 +34,11 @@ tracing = "0.1"
|
||||
# `set_last_decode_reachability`, for consumers' tests only (enable in dev-dependencies).
|
||||
test-hooks = []
|
||||
|
||||
[dev-dependencies]
|
||||
# `test_util` fixtures (`decrypt_unit`, …) for this crate's tests (keys-upfront
|
||||
# KU-P1). CI resolves it through the sibling path patch.
|
||||
libfreemkv = { version = "1.7", features = ["test-util"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
+32
-4
@@ -456,10 +456,10 @@ impl KeydbSource {
|
||||
|
||||
// On a match with no key: carry the specific reason if we have one, else
|
||||
// leave it empty for the library to render a bare `NoDerivableKey`.
|
||||
let miss_path = if keys.is_empty() {
|
||||
miss_reason.map(|n| vec![n]).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
// KU J23: `NoVid` stays beside partial keys (the VID would derive the rest).
|
||||
let miss_path = match miss_reason {
|
||||
Some(n) if keys.is_empty() || n == KeyNode::NoVid => vec![n],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
KeydbResolution {
|
||||
@@ -641,6 +641,12 @@ impl KeySource for KeydbSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by disc hash, not by samples: asked once per resolve, not per
|
||||
// piece (KU §2.3 step 8, N-KU10; KSK1).
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"keydb"
|
||||
}
|
||||
@@ -786,6 +792,19 @@ mod tests {
|
||||
assert!(!shape.vid_available);
|
||||
}
|
||||
|
||||
// KU J23: stored unit keys for SOME CPS units plus a Media Key with no VID. The stored
|
||||
// keys come back, and the miss path still says `NoVid`: the VID would derive the rest.
|
||||
#[test]
|
||||
fn partial_stored_keys_with_a_media_key_and_no_vid_report_no_vid() {
|
||||
let mut e = blank_entry(HASH);
|
||||
e.media_key = Some([0x33u8; 16]);
|
||||
e.unit_keys = vec![(1, [0x55u8; 16])];
|
||||
let db = db_with(e, Vec::new());
|
||||
let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]; 2], None));
|
||||
assert_eq!(r.keys.len(), 1, "the stored key");
|
||||
assert_eq!(r.miss_path, vec![KeyNode::NoVid]);
|
||||
}
|
||||
|
||||
// A hash that is NOT in the keydb is the one true miss: not matched, no
|
||||
// shape, no miss path (the library renders this as `no entry`).
|
||||
#[test]
|
||||
@@ -1142,6 +1161,15 @@ mod tests {
|
||||
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
||||
}
|
||||
|
||||
// KSK1: keyed by disc hash, so `resolve` asks it once per rip, not once per
|
||||
// piece (KU §2.3 step 8, N-KU10).
|
||||
#[test]
|
||||
fn keydb_source_answer_does_not_depend_on_samples() {
|
||||
assert!(!KeydbSource::new("/nonexistent/keydb.cfg").answer_depends_on_samples());
|
||||
// KU J23: a keydb's VID need is per disc (its `NoVid` miss path), not blanket.
|
||||
assert!(!KeydbSource::new("/nonexistent/keydb.cfg").uses_vid());
|
||||
}
|
||||
|
||||
// A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must
|
||||
// not be stuck un-settled forever — it now settles via the inode-identity
|
||||
// fallback once the observation has aged past the granularity.
|
||||
|
||||
+1
-1
@@ -2423,7 +2423,7 @@ mod tests {
|
||||
for entry in &candidate_entries {
|
||||
for (_, key) in &entry.unit_keys {
|
||||
let mut unit = original.clone();
|
||||
libfreemkv::aacs::content::decrypt_unit(&mut unit, key);
|
||||
libfreemkv::test_util::decrypt_unit(&mut unit, key);
|
||||
attempts += 1;
|
||||
any_key_changed_the_unit |= unit != original;
|
||||
if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs)
|
||||
|
||||
+105
-1
@@ -20,7 +20,8 @@ pub use keydb_format::{DiscEntry, KeyDb};
|
||||
pub use online::set_last_decode_reachability;
|
||||
pub use online::{
|
||||
DecodeReachability, KeyserverUrlFault, KeyserverUrlRejection, MIN_SAMPLE_UNITS, OnlineSource,
|
||||
check_keyserver_url, take_last_decode_reachability, validate_keyserver_url,
|
||||
check_keyserver_url, check_keyserver_url_static, take_last_decode_reachability,
|
||||
validate_keyserver_url,
|
||||
};
|
||||
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
|
||||
|
||||
@@ -149,6 +150,23 @@ impl KeySource for MultiSource {
|
||||
fn label(&self) -> &'static str {
|
||||
"multi"
|
||||
}
|
||||
|
||||
// KU3-6: depends on samples if ANY inner source does, so a nested
|
||||
// sample-dependent source keeps getting the per-piece ask.
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.answer_depends_on_samples())
|
||||
}
|
||||
|
||||
// Same `any(inner)` rule (KU-K1 review): `resolve` retries the composition
|
||||
// whenever any inner source's last failure was transport-class.
|
||||
fn last_failure_was_transport(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.last_failure_was_transport())
|
||||
}
|
||||
|
||||
// KU J23: any inner source that consumes the VID makes the composition one.
|
||||
fn uses_vid(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.uses_vid())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -214,6 +232,92 @@ mod tests {
|
||||
assert!(multi.host_certs(None).is_empty());
|
||||
}
|
||||
|
||||
// A source whose `answer_depends_on_samples` is fixed to the given value —
|
||||
// the only behaviour KSK2 exercises.
|
||||
struct FixedDependency(bool);
|
||||
impl KeySource for FixedDependency {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// KSK2: `MultiSource::answer_depends_on_samples` is `any(inner)`, so nesting
|
||||
// a sample-dependent online source under a sample-independent keydb still
|
||||
// gets the per-piece ask (`resolve` must not treat the pair as keydb-only).
|
||||
#[test]
|
||||
fn multi_source_answer_depends_on_samples_is_any_inner() {
|
||||
let all_independent = MultiSource::new(vec![
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!all_independent.answer_depends_on_samples());
|
||||
|
||||
let keydb_then_online = MultiSource::new(vec![
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedDependency(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(keydb_then_online.answer_depends_on_samples());
|
||||
}
|
||||
|
||||
// A source whose `uses_vid` is fixed to the given value.
|
||||
struct FixedVid(bool);
|
||||
impl KeySource for FixedVid {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn uses_vid(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// KU J23: like `answer_depends_on_samples`, a composed source uses the VID if any
|
||||
// inner one does, so a nested online source still makes a Missing piece VID-derivable.
|
||||
#[test]
|
||||
fn multi_source_uses_vid_is_any_inner() {
|
||||
let neither = MultiSource::new(vec![
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!neither.uses_vid());
|
||||
let keydb_then_online = MultiSource::new(vec![
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedVid(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(keydb_then_online.uses_vid());
|
||||
}
|
||||
|
||||
// A source whose `last_failure_was_transport` is fixed to the given value.
|
||||
struct FixedTransport(bool);
|
||||
impl KeySource for FixedTransport {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn last_failure_was_transport(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// For consistency with `answer_depends_on_samples` (KU3-6): a composed
|
||||
// source's last-failure verdict is `any(inner)` too, so `resolve` retries
|
||||
// the composition whenever ANY inner source's last failure was transport.
|
||||
#[test]
|
||||
fn multi_source_last_failure_was_transport_is_any_inner() {
|
||||
let none_transport = MultiSource::new(vec![
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!none_transport.last_failure_was_transport());
|
||||
|
||||
let one_transport = MultiSource::new(vec![
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedTransport(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(one_transport.last_failure_was_transport());
|
||||
}
|
||||
|
||||
/// The `mkb` generation must reach each inner source so its OWN revocation
|
||||
/// filter can act; a source that drops everything at a given generation is
|
||||
/// honoured by the union.
|
||||
|
||||
+244
-2
@@ -3,6 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -176,6 +177,10 @@ fn split_authority(url: &str) -> Result<(String, u16), (GuardFail, String)> {
|
||||
Ok((host, port))
|
||||
}
|
||||
|
||||
// Host lookups started, for the tests that prove a check made none.
|
||||
#[cfg(test)]
|
||||
static DNS_LOOKUPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
// Resolve `url`'s host, validating every address against the SSRF guard;
|
||||
// returns pinned socket addrs or a rejection reason + message. SECURITY: the
|
||||
// message names the address — log only at config time, never in `query`.
|
||||
@@ -206,6 +211,8 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
}
|
||||
let host = host.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
#[cfg(test)]
|
||||
DNS_LOOKUPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
std::thread::spawn(move || {
|
||||
let res = (host.as_str(), port)
|
||||
.to_socket_addrs()
|
||||
@@ -307,6 +314,23 @@ impl From<(GuardFail, String)> for KeyserverUrlRejection {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key-service URL checks that need no DNS lookup: `https`, a host, a valid port, and
|
||||
/// no literal non-public address (SSRF guard). Every rejection is
|
||||
/// [`KeyserverUrlFault::Permanent`]. For a factory build, which no Stop can reach: the host
|
||||
/// lookup (and its guard) runs at the first query, on the source's worker. That query is
|
||||
/// not yet Stop-aware: it runs under its own `Halt` until ST-K1b wires `ctx.halt()` (J10).
|
||||
pub fn check_keyserver_url_static(url: &str) -> Result<(), KeyserverUrlRejection> {
|
||||
let (host, _) = split_authority(url).map_err(KeyserverUrlRejection::from)?;
|
||||
let literal = host.trim_start_matches('[').trim_end_matches(']');
|
||||
match literal.parse::<IpAddr>() {
|
||||
Ok(ip) if is_blocked_ip(&ip) => Err(KeyserverUrlRejection::from((
|
||||
GuardFail::Config,
|
||||
format!("refusing to connect to non-public address {ip} (SSRF guard)"),
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`validate_keyserver_url`] with a typed verdict: [`KeyserverUrlFault::Temporary`] only when
|
||||
/// the host lookup failed, so a caller can keep the source and retry at request time.
|
||||
pub fn check_keyserver_url(url: &str) -> Result<(), KeyserverUrlRejection> {
|
||||
@@ -608,6 +632,9 @@ pub struct OnlineSource {
|
||||
/// the identical address set, so the anti-rebinding guarantee is untouched: only the pooled
|
||||
/// TLS connection is reused, never a stale, un-reguarded address.
|
||||
agent: AgentCache,
|
||||
/// KU-K1 (J15): whether the most recent `query` failure was transport-class
|
||||
/// (`DecodeReachability::Transport`). Reset on every success.
|
||||
last_failure_transport: AtomicBool,
|
||||
#[cfg(test)]
|
||||
test_net: Option<TestNet>,
|
||||
}
|
||||
@@ -641,6 +668,7 @@ impl OnlineSource {
|
||||
base_url: base_url.into(),
|
||||
secret: secret.into(),
|
||||
agent: Arc::new(Mutex::new(None)),
|
||||
last_failure_transport: AtomicBool::new(false),
|
||||
#[cfg(test)]
|
||||
test_net: None,
|
||||
}
|
||||
@@ -747,6 +775,16 @@ impl OnlineSource {
|
||||
.unwrap_or_else(|_| self.base_url.clone());
|
||||
run_on_worker(&host, halt, move || job.run())
|
||||
}
|
||||
|
||||
// KU-K1 (J15): classify the query just finished, from a PEEK (never a
|
||||
// `take`) at the slot — a caller downstream of `resolve` (freemkv-library's
|
||||
// server) still needs to read the same verdict once (`query_with` clears it).
|
||||
fn record_last_failure_transport(&self, result: &Result<Vec<UnitKey>, Error>) {
|
||||
let transport = result.is_err()
|
||||
&& LAST_DECODE_REACHABILITY.with(|c| c.get()) == Some(DecodeReachability::Transport);
|
||||
self.last_failure_transport
|
||||
.store(transport, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One key-service round-trip, owned so it can run on a worker thread.
|
||||
@@ -1065,14 +1103,18 @@ impl KeySource for OnlineSource {
|
||||
// Base per-CPS-unit Unit Keys via `query`: `Ok(empty)` means the service answered with no
|
||||
// key, `Err` means it could not answer.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.query_with(ctx, &Halt::new())
|
||||
let result = self.query_with(ctx, &Halt::new());
|
||||
self.record_last_failure_transport(&result);
|
||||
result
|
||||
}
|
||||
|
||||
// AACS 2.1 forensic index set: same `query` round-trip as
|
||||
// `get_unit_keys`, but the mux's samples are a single-phase anchor batch
|
||||
// and the service's array position tags each forensic index.
|
||||
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.query_with(ctx, &Halt::new())
|
||||
let result = self.query_with(ctx, &Halt::new());
|
||||
self.record_last_failure_transport(&result);
|
||||
result
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -1081,6 +1123,17 @@ impl KeySource for OnlineSource {
|
||||
|
||||
// host_certs: no-op default. No online cert fetch/endpoint today, so
|
||||
// OEM certs fall back to another source (e.g. keydb); no network touched.
|
||||
|
||||
// KU-K1 (J15): only a transport-class failure (no answer at all) is retried by `resolve`.
|
||||
fn last_failure_was_transport(&self) -> bool {
|
||||
self.last_failure_transport.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// KU J23: the service derives keys from the VID it is sent (`vid_b64`), so a Missing
|
||||
// piece might open with the disc's VID in hand.
|
||||
fn uses_vid(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// The `Authorization` header value, or `None` when no secret is configured
|
||||
@@ -1421,6 +1474,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Stop rule (stall-based only, Stop can interrupt every wait): a factory build has no
|
||||
// Halt, so its URL check does no DNS. It rejects what is wrong without a lookup and
|
||||
// leaves the host lookup to the first query (not Stop-aware until ST-K1b, J10).
|
||||
#[test]
|
||||
fn the_static_check_rejects_config_faults_without_a_lookup() {
|
||||
for url in [
|
||||
"http://8.8.8.8/keys",
|
||||
"ftp://example.com/keys",
|
||||
"https:///keys",
|
||||
"https://8.8.8.8:notaport/keys",
|
||||
"https://[::1/keys",
|
||||
"https://127.0.0.1/keys",
|
||||
"https://169.254.169.254/latest/meta-data",
|
||||
"https://[::1]:8443/keys",
|
||||
] {
|
||||
let r = check_keyserver_url_static(url).expect_err(url);
|
||||
assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}");
|
||||
}
|
||||
// A host name is not looked up: `.test` (RFC 2606) never resolves, yet it passes here.
|
||||
assert_eq!(
|
||||
check_keyserver_url_static("https://keys.ku-e1.test/keys"),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(check_keyserver_url_static("https://8.8.8.8/keys"), Ok(()));
|
||||
let dns_before = DNS_LOOKUPS.load(Ordering::SeqCst);
|
||||
let _ = check_keyserver_url_static("https://keys.example.com/keys");
|
||||
assert_eq!(
|
||||
DNS_LOOKUPS.load(Ordering::SeqCst),
|
||||
dns_before,
|
||||
"no DNS lookup"
|
||||
);
|
||||
}
|
||||
|
||||
// The typed verdict autorip needs instead of matching message text: a standing fault
|
||||
// (scheme, host, port, blocked address) is Permanent; only a failed lookup is Temporary.
|
||||
#[test]
|
||||
@@ -2838,4 +2924,160 @@ mod tests {
|
||||
);
|
||||
assert_eq!(take_last_decode_reachability(), None);
|
||||
}
|
||||
|
||||
// ── KU-K1: `last_failure_was_transport` (J13, J15) ──────────────────────
|
||||
|
||||
// KU J23: the service derives keys from the VID it is sent (`vid_b64`; KS-16 "Kvu =
|
||||
// AES-G(Km, IDv)"), so a Missing piece might open with the disc's VID in hand (E7034).
|
||||
#[test]
|
||||
fn online_source_uses_the_vid() {
|
||||
assert!(OnlineSource::new("https://keyserver.test/keys", "s3cr3t").uses_vid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_failure_was_transport_is_false_before_any_query() {
|
||||
let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t");
|
||||
assert!(!src.last_failure_was_transport());
|
||||
}
|
||||
|
||||
// Per J15/J13: a DNS failure never answers, so it is transport-class and gets retried.
|
||||
#[test]
|
||||
fn last_failure_was_transport_true_after_dns_failure() {
|
||||
let src = source_via("kuk1a.test", T_IDLE, || {
|
||||
Err((GuardFail::Unreachable, "did not resolve".into()))
|
||||
});
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
src.last_failure_was_transport(),
|
||||
"a DNS failure is transport-class"
|
||||
);
|
||||
}
|
||||
|
||||
// A refused connection never answers either — transport-class (J13's "connect").
|
||||
#[test]
|
||||
fn last_failure_was_transport_true_after_connect_refused() {
|
||||
let refused: SocketAddr = ([127, 0, 0, 1], 1).into();
|
||||
let src = source_via("kuk1b.test", T_IDLE, move || Ok(vec![refused]));
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
src.last_failure_was_transport(),
|
||||
"a refused connection is transport-class"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop-design-v5 T16: "no bytes moved" for the idle bound is a transport-class timeout.
|
||||
#[test]
|
||||
fn last_failure_was_transport_true_after_idle_timeout() {
|
||||
let addr = stub_server(Stub::NeverAnswer);
|
||||
let src = source_via("kuk1c.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
src.last_failure_was_transport(),
|
||||
"an idle stall is transport-class"
|
||||
);
|
||||
}
|
||||
|
||||
// J15: a 5xx (or any decode reply) IS an answer, so it must never look transport-class —
|
||||
// that would re-ask a source the service already answered ("never call it twice").
|
||||
#[test]
|
||||
fn last_failure_was_transport_false_after_5xx() {
|
||||
let addr = stub_server(Stub::Answer(503));
|
||||
let src = source_via("kuk1d.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
!src.last_failure_was_transport(),
|
||||
"the service answered (5xx) — never re-asked"
|
||||
);
|
||||
}
|
||||
|
||||
// J15: "reset it on success" — a later answer must clear a prior transport verdict.
|
||||
#[test]
|
||||
fn last_failure_was_transport_resets_on_success() {
|
||||
let ok_addr = stub_server(Stub::Answer(200));
|
||||
let refused: SocketAddr = ([127, 0, 0, 1], 1).into();
|
||||
let target = Arc::new(Mutex::new(refused));
|
||||
let for_resolve = target.clone();
|
||||
let src = source_via("kuk1e.test", T_IDLE, move || {
|
||||
Ok(vec![*for_resolve.lock().unwrap()])
|
||||
});
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(src.last_failure_was_transport());
|
||||
*target.lock().unwrap() = ok_addr;
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_ok());
|
||||
assert!(
|
||||
!src.last_failure_was_transport(),
|
||||
"a success must reset the flag"
|
||||
);
|
||||
}
|
||||
|
||||
// Regression: freemkv-library's server takes the reachability slot itself
|
||||
// after `resolve`, to classify a no-key without a second probe.
|
||||
// `last_failure_was_transport` must PEEK it, not TAKE it (never ask twice).
|
||||
#[test]
|
||||
fn recording_last_failure_does_not_erase_the_reachability_slot() {
|
||||
let addr = stub_server(Stub::Answer(422));
|
||||
let src = source_via("kuk1f.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Status(422)),
|
||||
"get_unit_keys must leave the slot for a later caller (e.g. the server) to read"
|
||||
);
|
||||
|
||||
let addr = stub_server(Stub::Answer(422));
|
||||
let src = source_via("kuk1g.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).is_err());
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Status(422)),
|
||||
"get_fmts_indexes must leave the slot too"
|
||||
);
|
||||
}
|
||||
|
||||
// The base and forensic paths share `query_with`, so the flag must update
|
||||
// through `get_fmts_indexes` exactly as it does through `get_unit_keys`.
|
||||
#[test]
|
||||
fn last_failure_was_transport_true_through_get_fmts_indexes() {
|
||||
let addr = stub_server(Stub::NeverAnswer);
|
||||
let src = source_via("kuk1h.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_fmts_indexes(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
src.last_failure_was_transport(),
|
||||
"get_fmts_indexes must classify the failure too"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop-design-v5 T16: a reply that stalls mid-BODY (not just before the
|
||||
// first byte) is still "no bytes moved" past idle — transport-class.
|
||||
#[test]
|
||||
fn last_failure_was_transport_true_after_mid_body_stall() {
|
||||
let addr = stub_server(Stub::StallBody);
|
||||
let src = source_via("kuk1i.test", T_IDLE, move || Ok(vec![addr]));
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
src.last_failure_was_transport(),
|
||||
"a mid-body stall is transport-class"
|
||||
);
|
||||
}
|
||||
|
||||
// J15: a definite answer after a transport failure must clear the flag — the
|
||||
// NEXT verdict always wins, so a stale `true` never survives past one query.
|
||||
#[test]
|
||||
fn last_failure_was_transport_resets_from_true_on_5xx() {
|
||||
let refused: SocketAddr = ([127, 0, 0, 1], 1).into();
|
||||
let answering = stub_server(Stub::Answer(503));
|
||||
let target = Arc::new(Mutex::new(refused));
|
||||
let for_resolve = target.clone();
|
||||
let src = source_via("kuk1j.test", T_IDLE, move || {
|
||||
Ok(vec![*for_resolve.lock().unwrap()])
|
||||
});
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(src.last_failure_was_transport());
|
||||
*target.lock().unwrap() = answering;
|
||||
assert!(src.get_unit_keys(&ctx_with_mkb(0)).is_err());
|
||||
assert!(
|
||||
!src.last_failure_was_transport(),
|
||||
"a 5xx answer must clear a prior transport verdict"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user