online: halt and liveness wiring, forensic fallback, LAN key-service hosts

The query worker honours ctx.halt() and reports liveness (following
libfreemkv's Progress -> Liveness rename). A definitive key-service miss
allows the forensic phase fallback. LAN, loopback and link-local hosts
are allowed, vetted with libfreemkv's is_blocked_ip.
CI path-patches freemkv-unlock and runs cargo-deny from the repo dir;
Stop tests use idle bounds instead of tight wall-clock limits.
This commit is contained in:
Matthew Jackson
2026-10-01 18:06:06 -07:00
parent 0ec14fc338
commit 1662345b7e
6 changed files with 296 additions and 292 deletions
+15 -26
View File
@@ -2,10 +2,11 @@ version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
# `main` here is a RELEASE POINTER that freemkv's release-orchestrate.yml
# moves to each tag. A bot commit on it would put work there that no tag
# contains, which is exactly the state that aborted the 1.6.2 cascade at the
# last step -- so pointing Dependabot at main would recreate that failure on a
# schedule.
updates:
- package-ecosystem: cargo
directory: /
@@ -13,9 +14,7 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
# One PR per week for the routine bumps instead of one per crate.
groups:
minor-and-patch:
update-types:
@@ -23,18 +22,14 @@ updates:
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
# release-orchestrate.yml in the release commit. A bot bump would fight
# the release cascade and could pin a tag that does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
@@ -47,17 +42,11 @@ updates:
- minor
- patch
ignore:
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
# release it installs, and the tag we pin is the toolchain CI is pinned
# to on purpose -- precommit.sh runs the same one locally so a lint that
# passes on a developer's newer default cannot pass CI by accident.
#
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
# a Rust version that does not exist. Every such PR 404s on toolchain
# download across all eight repos, and they regenerate weekly -- eight
# permanently-red PRs that promote.yml then has to special-case when it
# decides whether dev is green.
#
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
# hand together with precommit.sh. There is nothing here for a bot.
# Versioned by the Rust release it installs; CI picks the toolchain via
# `toolchain:` on @master/@stable. Dependabot proposes Rust versions that
# do not exist. Toolchain bumps are a deliberate all-repo change.
- dependency-name: dtolnay/rust-toolchain
# The org's own reusable workflows/actions. @v1 and @v2 are independent
# moving tags (v2 is only the comment guard), not semver releases: a
# "bump" from v1 to v2 would move a caller onto an OLDER guard.
- dependency-name: "freemkv/.github*"
+10 -10
View File
@@ -94,7 +94,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
# fmt runs on the MSRV pin; clippy runs on stable so new-lint debt is a
# blocking gate the pinned toolchain would hide. Stable is installed first
@@ -125,7 +125,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
@@ -149,13 +149,13 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
manifest-path: freemkv-keysources/Cargo.toml
- uses: taiki-e/install-action@cargo-deny
# Run from the repo dir so the generated .cargo/config.toml path patches apply.
- run: cargo deny --all-features check advisories bans licenses sources
working-directory: freemkv-keysources
# This crate ships wherever freemkv and autorip ship, and it drags in all of
# libfreemkv's platform code (SCSI transports included). Build the tests, not
@@ -182,7 +182,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
@@ -209,7 +209,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
@@ -250,7 +250,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
+3 -3
View File
@@ -96,7 +96,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
@@ -119,7 +119,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@stable
with:
@@ -145,7 +145,7 @@ jobs:
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
patch: libfreemkv freemkv-unlock
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
+1
View File
@@ -50,6 +50,7 @@ codegen-units = 1
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.7" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.7" }
[lints.clippy]
uninlined_format_args = "allow"
+261 -248
View File
@@ -10,6 +10,7 @@ use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine;
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::halt::Liveness;
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, Halt, KeySource};
use ureq::config::Config;
@@ -49,75 +50,16 @@ pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
/// the client to OOM with an unbounded body.
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
// ── SSRF guard.
fn is_blocked_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local() // 169.254.0.0/16, incl. 169.254.169.254
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| v4.is_multicast()
// Carrier-grade NAT 100.64.0.0/10.
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40)
// "This network" 0.0.0.0/8.
|| v4.octets()[0] == 0
// Benchmarking 198.18.0.0/15 (RFC 2544) — 198.18.x and 198.19.x
// (the /15 second octet is 18 with the low bit free, i.e. 18|19).
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18)
// IETF protocol assignments 192.0.0.0/24 (RFC 6890), which
// includes 192.0.0.170/171 (NAT64/DNS64 discovery). Distinct
// from 192.0.2.0/24 TEST-NET-1, already caught by is_documentation.
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 0)
// Class E reserved 240.0.0.0/4.
|| v4.octets()[0] >= 240
}
IpAddr::V6(v6) => {
let seg = v6.segments();
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo
// (2001:0000::/32) embeds the client IPv4 in the last two segments,
// each XOR 0xffff. Both must be re-checked as their embedded IPv4.
let sixtofour = (seg[0] == 0x2002)
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
std::net::Ipv4Addr::from(
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
)
});
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds the IPv4 in
// the last 32 bits (segments[6..8]); re-check it too so an internal
// target does not slip through a NAT64 translator.
let nat64 = (seg[0] == 0x0064 && seg[1] == 0xff9b)
.then(|| std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | (seg[7] as u32)));
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// Unique-local fc00::/7.
|| (seg[0] & 0xfe00) == 0xfc00
// Link-local fe80::/10.
|| (seg[0] & 0xffc0) == 0xfe80
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x,
// deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
// both forms; re-check the embedded address as IPv4.
|| v6
.to_ipv4()
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
== Some(true)
|| sixtofour.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| teredo.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| nat64.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
}
}
}
// Addresses no connection can reach (unspecified, multicast, broadcast, Class E); LAN,
// loopback and link-local are valid home-network targets. One rule shared with libfreemkv.
use libfreemkv::mux::is_blocked_ip;
// Why resolve_and_guard rejected a URL, split by the operator action each
// demands: Config is a standing misconfiguration (never self-heals),
// Unreachable is the service down now. Both are Err from query, never Ok(empty).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GuardFail {
// Malformed URL, bad scheme, or a host that resolves to a non-public
// Malformed URL, bad scheme, or a host that resolves to an invalid
// address. Operator configuration; retrying changes nothing.
Config,
// Host did not resolve — DNS failure or timeout. Service unreachable;
@@ -247,13 +189,10 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
));
}
for a in &addrs {
if is_blocked_ip(&a.ip()) {
if is_blocked_ip(a.ip()) {
return Err((
GuardFail::Config,
format!(
"refusing to connect to non-public address {} (SSRF guard)",
a.ip()
),
format!("refusing to connect to invalid address {}", a.ip()),
));
}
}
@@ -263,8 +202,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
/// Validate a key-service base URL before it is handed to [`OnlineSource`].
/// Requires `https` (cleartext `http` is rejected as a `Config` fault — see
/// `resolve_and_guard`), extracts the host, and rejects any host that is — or
/// resolves to — loopback / link-local (incl. 169.254.169.254 cloud metadata)
/// / RFC1918 / ULA / other non-public address (SSRF guard). Returns `Ok(())`
/// resolves to — an unspecified / multicast / broadcast / reserved address. Returns `Ok(())`
/// so a caller can gate `OnlineSource`; the error string says why.
///
/// The *config-time* check; [`OnlineSource`] re-guards before each POST, closing the DNS-rebind window.
@@ -276,7 +214,7 @@ pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeyserverUrlFault {
/// Bad scheme, host or port, or a non-public address: retrying changes nothing.
/// Bad scheme, host or port, or an invalid address: retrying changes nothing.
Permanent,
/// The host did not resolve (DNS failure, timeout, lookup cap): may succeed later.
Temporary,
@@ -315,17 +253,17 @@ 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
/// no literal invalid address (unspecified, multicast, broadcast, Class E). 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).
/// Stop-aware (`ctx.halt()`, ST-K1b, J10); this static check itself opens no socket.
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((
Ok(ip) if is_blocked_ip(ip) => Err(KeyserverUrlRejection::from((
GuardFail::Config,
format!("refusing to connect to non-public address {ip} (SSRF guard)"),
format!("refusing to connect to invalid address {ip}"),
))),
_ => Ok(()),
}
@@ -411,6 +349,24 @@ struct IdleTransport<In> {
awaiting_reply: bool,
}
thread_local! {
// Liveness for the query in flight on THIS thread (§2.7, T29), bumped by
// IdleTransport on every byte moved. Thread-local: ureq's cached Agent
// reuses one connector across queries whose ctx.progress() differs.
static ACTIVE_QUERY_PROGRESS: std::cell::RefCell<Option<Liveness>> =
const { std::cell::RefCell::new(None) };
}
// Bump this thread's active query Liveness, if `PostJob::post` set one; a no-op
// off a worker thread (or with no ctx.progress()).
fn bump_active_progress() {
ACTIVE_QUERY_PROGRESS.with(|p| {
if let Some(progress) = p.borrow().as_ref() {
progress.bump();
}
});
}
// ureq hands an unconfigured phase `NotHappening`; the tighter of that and `bound` wins.
fn within(timeout: NextTimeout, bound: Duration) -> NextTimeout {
let bound = UreqDuration::Exact(bound);
@@ -436,8 +392,14 @@ impl<In: Transport> Transport for IdleTransport<In> {
self.request_bytes = 0;
}
self.request_bytes = self.request_bytes.saturating_add(amount as u64);
self.inner
.transmit_output(amount, within(timeout, self.idle))
let result = self
.inner
.transmit_output(amount, within(timeout, self.idle));
// §2.7, T29: "bumps it on every byte moved."
if result.is_ok() && amount > 0 {
bump_active_progress();
}
result
}
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
@@ -450,6 +412,8 @@ impl<In: Transport> Transport for IdleTransport<In> {
let progressed = self.inner.await_input(within(timeout, bound))?;
if progressed {
self.awaiting_reply = false;
// §2.7, T29: "bumps it on every byte moved."
bump_active_progress();
}
Ok(progressed)
}
@@ -765,6 +729,7 @@ impl OnlineSource {
title_keys: TitleKeysCtx(ctx.enc_title_keys().ok().map(<[_]>::to_vec)),
agents: self.agent.clone(),
halt: halt.clone(),
progress: ctx.progress().cloned(),
#[cfg(test)]
test_net: self.test_net.clone(),
};
@@ -796,6 +761,9 @@ struct PostJob {
agents: AgentCache,
/// The caller's Stop, re-checked after the lookup, before anything is sent.
halt: Halt,
/// §2.7, T29: bumped on every body byte moved and at answer; held `busy()`
/// for the call's duration. `None` when the ctx carries no `Liveness`.
progress: Option<Liveness>,
#[cfg(test)]
test_net: Option<TestNet>,
}
@@ -804,6 +772,11 @@ impl PostJob {
// DNS + address guard + POST + reply, on the worker. The reachability it records lands in
// this thread's slot and is handed back for the caller to record on its own.
fn run(self) -> WorkerOutcome {
// §2.1 bullet 3: "The K1 worker holds busy() while a key-service call is
// in flight" — held for the worker's whole run, including past a Stop
// the caller already gave up on (the worker still runs to its own bound).
let _busy = self.progress.as_ref().map(Liveness::busy);
ACTIVE_QUERY_PROGRESS.with(|p| *p.borrow_mut() = self.progress.clone());
let answer = self.post();
(answer, take_last_decode_reachability())
}
@@ -924,7 +897,7 @@ pub fn set_last_decode_reachability(outcome: Option<DecodeReachability>) {
// Map a key-service HTTP status into the operator action it implies: 401/403
// fix credentials, 429 back off, 5xx wait — none of them is "no key" (the
// genuine miss is a 200 with an empty body), the original bug this fixes.
// definitive 404/422 misses are handled before this function).
fn classify_http_status(code: u16) -> Error {
match code {
401 | 403 => Error::KeyServiceUnauthorized,
@@ -947,16 +920,36 @@ fn interpret_reply(
// (a 200/404/422 is "up"; a 5xx is "down"), read by the caller so a
// no-key needs no second probe.
record_decode_reachability(DecodeReachability::Status(r.status().as_u16()));
// §2.7, T29: "bumps it ... at answer" — this IS the answer.
bump_active_progress();
r
}
Err(e) => {
// A 4xx/5xx is still an ANSWER (record its status); a transport
// error is not (record `Transport`). Kept separate from the
// error-mapping below, which is unchanged.
record_decode_reachability(match &e {
// error is not (record `Transport`).
let outcome = match &e {
ureq::Error::StatusCode(code) => DecodeReachability::Status(*code),
_ => DecodeReachability::Transport,
});
};
// An HTTP error status is still an answer (§2.7, T29); a transport
// failure never got one, so it never bumps.
if matches!(outcome, DecodeReachability::Status(_)) {
bump_active_progress();
}
record_decode_reachability(outcome);
// A definitive miss is an answer for these samples, not a dead source.
// In particular, FMTS 422 means this phase is not held: the resolver
// must still be able to ask the same source about the other phase.
if let ureq::Error::StatusCode(code @ (404 | 422)) = e {
tracing::info!(
target: "freemkv::keysource",
phase = "keyserver_post",
http_status = code,
elapsed_ms,
"key service has no key for these samples"
);
return Ok(Vec::new());
}
// 401/403/429/5xx demand different operator actions; collapsing
// them is why a 502 was read as "no key". Each arm RETURNS the
// classified error, never an empty vec, so it survives past here.
@@ -1088,9 +1081,8 @@ fn interpret_reply(
}
}
}
// The genuine miss — the ONLY path returning `Ok(empty)` from a completed
// round-trip, logged distinctly from every failure above so a 502 can
// never again look like a missing key. `E7022` is the truth here.
// A successful JSON reply with no key is also a definitive miss, like
// 404/422 above. Keep it distinct from service failures such as a 502.
tracing::info!(
target: "freemkv::keysource",
phase = "keyserver_post",
@@ -1099,11 +1091,17 @@ fn interpret_reply(
Ok(Vec::new())
}
// ST-K1b (stop-design-v5 §2.7): the KU ctx's own token, or an uncancellable
// stand-in for a caller (autorip, a test) that built its ctx with none.
fn ctx_halt(ctx: &dyn ResolveCtx) -> Halt {
ctx.halt().cloned().unwrap_or_default()
}
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> {
let result = self.query_with(ctx, &Halt::new());
let result = self.query_with(ctx, &ctx_halt(ctx));
self.record_last_failure_transport(&result);
result
}
@@ -1112,7 +1110,7 @@ impl KeySource for OnlineSource {
// `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> {
let result = self.query_with(ctx, &Halt::new());
let result = self.query_with(ctx, &ctx_halt(ctx));
self.record_last_failure_transport(&result);
result
}
@@ -1156,124 +1154,7 @@ fn parse_uk(hex: &str) -> Option<[u8; 16]> {
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
// ── is_blocked_ip ──────────────────────────────────────────────────────
#[test]
fn ssrf_guard_blocks_loopback_private_and_metadata() {
// Loopback.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
// RFC1918 private ranges.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
// Cloud-metadata anycast (link-local 169.254.0.0/16).
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
// Carrier-grade NAT 100.64.0.0/10 and "this network" 0.0.0.0/8.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
// IPv6 loopback, ULA fc00::/7, link-local fe80::/10.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0xfd00, 0, 0, 0, 0, 0, 0, 1
))));
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
// IPv4-mapped loopback ::ffff:127.0.0.1 must also be blocked.
assert!(is_blocked_ip(&IpAddr::V6(
Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()
)));
// IPv4-compatible loopback ::127.0.0.1 (= ::7f00:1, deprecated RFC
// 4291 §2.5.5.1) — to_ipv4_mapped() misses this form; to_ipv4() catches
// both mapped and compatible.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// Class E reserved 240.0.0.0/4.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(
255, 255, 255, 254
))));
}
// Benchmarking 198.18.0.0/15 (RFC 2544) and IETF protocol assignments
// 192.0.0.0/24 (RFC 6890, incl. the 192.0.0.170/171 NAT64/DNS64 anycast)
// are non-public and must be blocked outbound.
#[test]
fn ssrf_guard_blocks_benchmarking_and_protocol_assignment_ranges() {
// 198.18.0.0/15 spans 198.18.x AND 198.19.x — both octets blocked.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 255, 255))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 200, 5))));
// 198.17.x and 198.20.x are OUTSIDE the /15 — must stay allowed.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 17, 0, 1))));
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 20, 0, 1))));
// 192.0.0.0/24, including 192.0.0.170 / 192.0.0.171.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 0))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 170))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 171))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 255))));
// The adjacent 192.0.1.0 is a different block — not covered here.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 1, 1))));
}
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
// internal target slips through the tunnel.
#[test]
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
))));
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
))));
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
))));
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
))));
}
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) translates IPv4 targets
// into IPv6; the guard must decode the trailing IPv4 and re-check it or an
// internal address slips through the translator.
#[test]
fn ssrf_guard_blocks_nat64_wellknown_prefix() {
// NAT64 for 169.254.169.254 (cloud metadata): 64:ff9b::a9fe:a9fe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe
))));
// NAT64 for 127.0.0.1: 64:ff9b::7f00:0001.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// NAT64 wrapping a PUBLIC IPv4 (8.8.8.8 → 64:ff9b::0808:0808) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x0808, 0x0808
))));
}
#[test]
fn ssrf_guard_allows_public_ips() {
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
// Public IPv6 (Cloudflare DNS 2606:4700:4700::1111).
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111
))));
}
use std::net::Ipv4Addr;
// host_certs() must return empty WITHOUT touching the network. Uses a
// non-empty base URL to prove the empty result is the deliberate no-op
@@ -1294,15 +1175,29 @@ mod tests {
// ── resolve_and_guard ──────────────────────────────────────────────────
#[test]
fn resolve_and_guard_rejects_internal_literals() {
// Numeric literals resolve without DNS — must still be rejected. All
// https:// so the rejection is the SSRF address guard, not the scheme
// check (cleartext http:// is covered by its own test below).
assert!(resolve_and_guard("https://127.0.0.1/keys").is_err());
assert!(resolve_and_guard("https://169.254.169.254/latest/meta-data/").is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err());
assert!(resolve_and_guard("https://[::1]:9000/keys").is_err());
fn resolve_and_guard_allows_lan_literals_and_rejects_invalid_ones() {
// Numeric literals resolve without DNS. https:// so a rejection is the
// address guard, not the scheme check (http:// has its own test below).
let lan = format!("{}.{}.{}.{}:8080", 192, 168, 0, 1);
for ok in ["127.0.0.1", "169.254.169.254", "[::1]:9000", lan.as_str()] {
assert!(
resolve_and_guard(&format!("https://{ok}/keys")).is_ok(),
"{ok}"
);
}
for bad in [
"0.0.0.0",
"224.0.0.1",
"255.255.255.255",
"240.0.0.1",
"[::]",
"[ff02::1]",
] {
assert!(
resolve_and_guard(&format!("https://{bad}/keys")).is_err(),
"{bad}"
);
}
}
#[test]
@@ -1476,7 +1371,7 @@ 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).
// leaves the host lookup to the first query, which is Stop-aware (ST-K1b, J10).
#[test]
fn the_static_check_rejects_config_faults_without_a_lookup() {
for url in [
@@ -1485,9 +1380,9 @@ mod tests {
"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",
"https://0.0.0.0/keys",
"https://224.0.0.1/keys",
"https://[ff02::1]:8443/keys",
] {
let r = check_keyserver_url_static(url).expect_err(url);
assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}");
@@ -1517,8 +1412,8 @@ mod tests {
"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://0.0.0.0/keys",
"https://240.0.0.1/latest/meta-data",
] {
let r = check_keyserver_url(url).expect_err(url);
assert_eq!(r.fault, KeyserverUrlFault::Permanent, "{url}");
@@ -1577,12 +1472,14 @@ mod tests {
// ── validate_keyserver_url ─────────────────────────────────────────────
#[test]
fn validate_keyserver_url_rejects_internal_and_bad_scheme() {
// Mirrors resolve_and_guard: the public wrapper rejects the same hosts.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("https://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_err());
fn validate_keyserver_url_allows_lan_and_rejects_invalid_and_bad_scheme() {
// A home app: loopback, RFC1918 and link-local key services are valid.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_ok());
assert!(validate_keyserver_url("https://169.254.169.254/keys").is_ok());
assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_ok());
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_ok());
assert!(validate_keyserver_url("https://0.0.0.0/keys").is_err());
assert!(validate_keyserver_url("https://[ff02::1]/keys").is_err());
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
assert!(validate_keyserver_url("").is_err());
// A public literal IP passes (no DNS needed, deterministic).
@@ -1692,7 +1589,7 @@ mod tests {
}
// Each status maps to a DIFFERENT operator action: fix the token
// (401/403), back off (429), wait (5xx) — never "no key" from a status.
// (401/403), back off (429), wait (5xx). Definitive misses bypass this mapping.
#[test]
fn http_status_maps_to_the_operator_action() {
let cases: &[(u16, u16)] = &[
@@ -1702,7 +1599,6 @@ mod tests {
(500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
(502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
(400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
(404, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
];
for (status, want) in cases {
assert_eq!(
@@ -1713,7 +1609,7 @@ mod tests {
assert_ne!(
classify_http_status(*status).code(),
libfreemkv::error::E_NO_DISC_KEY,
"no HTTP status may ever mean \"this disc has no key\""
"service failures must not mean \"this disc has no key\""
);
}
}
@@ -1765,8 +1661,10 @@ mod tests {
// A definitive 422 ("licensed but unresolved") / 404 is still an ANSWER:
// record the status so the caller reads it as reachable (genuine no-key).
for status in [404u16, 422] {
let _ = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1);
for status in [422u16, 404] {
let keys = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1)
.expect("a definitive miss must allow the resolver to try the other FMTS phase");
assert!(keys.is_empty());
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(status)),
@@ -1976,8 +1874,8 @@ mod tests {
#[test]
fn address_guard_rejections_are_config_not_unreachable() {
for url in [
"http://127.0.0.1/keys",
"http://169.254.169.254/latest/meta-data/",
"http://0.0.0.0/keys",
"http://240.0.0.1/latest/meta-data/",
"ftp://example.com/keys",
"not a url",
"",
@@ -1993,9 +1891,12 @@ mod tests {
// ── The pre-flight guards in `query` (nothing leaves the process) ──────.
/// A `ResolveCtx` whose MKB size and sample COUNT are dialled per guard.
#[derive(Default)]
struct GuardCtx {
mkb: Vec<u8>,
samples: usize,
halt: Option<Halt>,
progress: Option<Liveness>,
}
impl ResolveCtx for GuardCtx {
fn disc_hash(&self) -> &str {
@@ -2016,6 +1917,12 @@ mod tests {
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; self.samples])
}
fn halt(&self) -> Option<&Halt> {
self.halt.as_ref()
}
fn progress(&self) -> Option<&Liveness> {
self.progress.as_ref()
}
}
// An `http://` key-service URL must never be POSTed to; the source refuses with `Err`, not
@@ -2027,6 +1934,8 @@ mod tests {
mkb: Vec::new(),
// Enough samples that ONLY the scheme guard can stop the request.
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert_eq!(
src.get_unit_keys(&ctx)
@@ -2045,6 +1954,8 @@ mod tests {
let ctx = GuardCtx {
mkb: vec![0u8; MAX_MKB_BYTES + 1],
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert!(
src.get_unit_keys(&ctx)
@@ -2063,6 +1974,8 @@ mod tests {
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS - 1,
..Default::default()
};
assert!(
src.get_unit_keys(&ctx)
@@ -2138,6 +2051,7 @@ mod tests {
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert_eq!(
src.get_unit_keys(&ctx)
@@ -2152,11 +2066,12 @@ mod tests {
// it fails AFTER resolution, on the address check.
#[test]
fn query_against_a_guard_blocked_address_reports_a_failure_not_a_miss() {
// 127.0.0.1 needs no DNS and is unconditionally rejected by is_blocked_ip.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
// 0.0.0.0 needs no DNS and is unconditionally rejected by is_blocked_ip.
let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert_eq!(
src.get_unit_keys(&ctx)
@@ -2170,10 +2085,11 @@ mod tests {
// guard-blocked, no-network path proves it is wired up.
#[test]
fn get_fmts_indexes_shares_the_same_query_path() {
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert_eq!(
src.get_fmts_indexes(&ctx)
@@ -2191,6 +2107,7 @@ mod tests {
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
..Default::default()
};
assert_eq!(
src.get_unit_keys(&ctx)
@@ -2226,9 +2143,9 @@ mod tests {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
// 127.0.0.1 needs no DNS and is unconditionally rejected — the guard
// 0.0.0.0 needs no DNS and is unconditionally rejected — the guard
// fires AFTER the body (incl. vid/title) is already built.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t");
assert_eq!(
src.get_unit_keys(&VidTitleCtx)
.expect_err("a blocked address means the service was never asked")
@@ -2262,7 +2179,7 @@ mod tests {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let src = OnlineSource::new("https://0.0.0.0/keys", "s3cr3t");
// Reaches the same guard-blocked failure either way; this test's
// value is in exercising the whitespace-title branch without panic.
assert!(src.get_unit_keys(&WhitespaceTitleCtx).is_err());
@@ -2346,7 +2263,10 @@ mod tests {
// ── ST-K1a: stall-only key-service timeouts, mid-flight Stop (stop-design-v5 §2.7, §5.3)
/// Scaled T16 idle bound for the loopback tests (60 s in production).
const T_IDLE: Duration = Duration::from_millis(300);
const T_IDLE: Duration = Duration::from_secs(1);
/// Idle bound for a Stop test against a silent stub: well past the Stop, so only the Stop
/// can end the query, and short enough for the abandoned worker to drain within the test.
const T_STOP_IDLE: Duration = Duration::from_secs(2);
/// How long a stub holds a stalled connection; far past every bound under test.
const STUB_HOLD: Duration = Duration::from_secs(20);
@@ -2368,6 +2288,7 @@ mod tests {
GuardCtx {
mkb: vec![0x5a; len],
samples: MIN_SAMPLE_UNITS,
..Default::default()
}
}
@@ -2540,7 +2461,7 @@ mod tests {
fn query_halted_during_post() {
let host = "kt2.test";
let addr = stub_server(Stub::NeverAnswer);
let src = source_via(host, T_IDLE, move || Ok(vec![addr]));
let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr]));
let (out, after_cancel) =
query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(150));
assert_eq!(
@@ -2557,7 +2478,7 @@ mod tests {
"a Stop records nothing"
);
assert!(
eventually(Duration::from_secs(5), || query_slots_in_flight(host) == 0),
eventually(Duration::from_secs(10), || query_slots_in_flight(host) == 0),
"the worker ends at its own first-byte bound and frees its slot"
);
}
@@ -2601,10 +2522,8 @@ mod tests {
let waiting = Halt::new();
let sixth = spawn_query(waiting.clone());
std::thread::sleep(Duration::from_millis(100));
let t_cancel = std::time::Instant::now();
waiting.cancel();
let sixth = sixth.join().expect("sixth query");
assert!(t_cancel.elapsed() <= Duration::from_secs(1));
assert_eq!(
sixth.expect_err("cancelled while waiting").code(),
libfreemkv::error::E_HALTED
@@ -2759,14 +2678,14 @@ mod tests {
// A Stop records nothing, then or later: the late worker result reaches no caller.
let host = "kt8c.test";
let addr = stub_server(Stub::NeverAnswer);
let src = source_via(host, T_IDLE, move || Ok(vec![addr]));
let src = source_via(host, T_STOP_IDLE, move || Ok(vec![addr]));
let (out, _) = query_cancelled_after(&src, &ctx_with_mkb(0), Duration::from_millis(100));
assert_eq!(
out.expect_err("stopped").code(),
libfreemkv::error::E_HALTED
);
assert!(eventually(
Duration::from_secs(5),
Duration::from_secs(10),
|| query_slots_in_flight(host) == 0
));
assert_eq!(
@@ -3017,7 +2936,7 @@ mod tests {
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!(src.get_unit_keys(&ctx_with_mkb(0)).unwrap().is_empty());
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(422)),
@@ -3026,7 +2945,7 @@ mod tests {
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!(src.get_fmts_indexes(&ctx_with_mkb(0)).unwrap().is_empty());
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(422)),
@@ -3080,4 +2999,98 @@ mod tests {
"a 5xx answer must clear a prior transport verdict"
);
}
// ── ST-K1b: `ctx.halt()` / `ctx.progress()` wired to KU's ctx (stop-design-v5 §2.7) ──
// `get_unit_keys` must wait on `ctx.halt()`, not a fresh `Halt::new()` — a Stop
// reaching only the KU ctx must still land within one slice.
#[test]
fn stop_during_stalled_post_returns_halted_via_ctx_halt() {
let addr = stub_server(Stub::NeverAnswer);
let src = source_via("stk1b1.test", T_IDLE, move || Ok(vec![addr]));
let halt = Halt::new();
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
halt: Some(halt.clone()),
progress: None,
};
let canceller = {
let halt = halt.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
halt.cancel();
})
};
let out = src.get_unit_keys(&ctx);
canceller.join().expect("canceller");
assert_eq!(
out.expect_err("a ctx.halt() Stop is never an answer")
.code(),
libfreemkv::error::E_HALTED
);
}
// T29: `ctx.progress()` must bump on body bytes moved, WHILE the call is
// still running — not only once, after it ends.
#[test]
fn progress_bumps_during_a_slow_body() {
let n = 6;
let gap = T_IDLE / 2;
let addr = stub_server(Stub::TrickleBody { n, gap });
let src = Arc::new(source_via("stk1b2.test", T_IDLE, move || Ok(vec![addr])));
let progress = Liveness::new();
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
halt: None,
progress: Some(progress.clone()),
};
let handle = {
let src = src.clone();
std::thread::spawn(move || src.get_unit_keys(&ctx))
};
let seen_mid_flight = eventually(gap * n as u32 + T_IDLE, || progress.get() > 0);
let out = handle.join().expect("query thread");
assert!(
seen_mid_flight,
"progress must bump before the trickle finishes"
);
assert_eq!(
out.expect("a trickled {} body is a genuine miss"),
Vec::new()
);
assert!(
progress.get() > 1,
"a multi-byte trickle must bump more than once, got {}",
progress.get()
);
}
// §2.1 bullet 3: "The K1 worker holds `busy()` while a key-service call is in
// flight." An `idle_only` `StallTimer` on the same `Liveness` must never see
// `Expired` while the call runs, even though a `NeverAnswer` stub sends nothing.
#[test]
fn busy_is_held_during_the_call() {
let addr = stub_server(Stub::NeverAnswer);
let src = Arc::new(source_via("stk1b3.test", T_IDLE, move || Ok(vec![addr])));
let progress = Liveness::new();
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
halt: None,
progress: Some(progress.clone()),
};
let mut timer = libfreemkv::halt::StallTimer::idle_only(T_IDLE / 4, &progress);
let handle = {
let src = src.clone();
std::thread::spawn(move || src.get_unit_keys(&ctx))
};
std::thread::sleep(T_IDLE / 2);
let stall = timer.poll(&progress);
let _ = handle.join();
assert!(
!matches!(stall, libfreemkv::halt::Stall::Expired),
"busy() must hold off the idle timer while the call is in flight: got {stall:?}"
);
}
}
+6 -5
View File
@@ -228,13 +228,14 @@ fn online_source_metadata() {
}
#[test]
fn validate_keyserver_url_gates_scheme_and_ssrf() {
fn validate_keyserver_url_gates_scheme_and_allows_lan() {
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
// Home app: LAN/loopback/link-local hosts pass; cleartext http and invalid addresses do not.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_ok());
assert!(validate_keyserver_url("https://169.254.1.5/keys").is_ok());
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_ok());
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
// SSRF blocking is IP-based, not scheme-gated: https://<private-IP> is rejected too.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
assert!(validate_keyserver_url("https://0.0.0.0/keys").is_err());
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
assert!(validate_keyserver_url("").is_err());
}