#![forbid(unsafe_code)]
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use crate::mixnet::driver::StatusPublisher;
use crate::mixnet::pools::exit_pool::ExitPoolError;
use crate::mixnet::supervisor::{MixnetProxy, MixnetProxyError};
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
#[error("this session acquires no transports")]
NoAcquirer,
#[error("could not run the discover mode")]
DiscoverySpawn(#[source] std::io::Error),
#[error("the discover mode exited {status}: {stderr}")]
DiscoveryFailed {
status: std::process::ExitStatus,
stderr: String,
},
#[error("the exit pool has no population yet")]
ExitPoolNotSeeded,
#[error("every exit is held: {held} held, {population} known")]
ExitPoolExhausted {
held: usize,
population: usize,
},
#[error(transparent)]
Proxy(#[from] MixnetProxyError),
#[error("the proxy host did not answer: {0}")]
HostUnavailable(String),
#[error("the proxy host refused")]
HostRefused(#[source] HostRefusal),
#[error(
"the ready transport reported no bound exit from the drawn clutch ({} reported)",
reported.len()
)]
ExitOutsideClutch {
reported: Vec<crate::mixnet::ExitNodeId>,
},
#[error("the transport died before it became ready")]
DiedDuringBootstrap {
#[source]
detail: Option<zingo_net_diag::NetOpFailure>,
},
#[error("the pool transport's status channel closed")]
StatusChannelClosed,
#[error("the pooled transport died before its run could use it")]
DiedBeforeUse,
#[error("the pool transport did not become ready within {}s", budget.as_secs())]
NotReady {
budget: std::time::Duration,
},
#[error(
"no proven exit: {probed} births each proved nothing within {}ms",
budget.as_millis()
)]
NoProvenExit {
probed: usize,
budget: std::time::Duration,
},
}
impl From<ExitPoolError> for TransportError {
fn from(refusal: ExitPoolError) -> Self {
match refusal {
ExitPoolError::NotSeeded => TransportError::ExitPoolNotSeeded,
ExitPoolError::Exhausted { held, population } => {
TransportError::ExitPoolExhausted { held, population }
}
}
}
}
pub(crate) trait TransportAcquirable: Send + Sync + 'static {
fn discover(
&self,
) -> impl Future<Output = Result<HashSet<crate::mixnet::ExitNodeId>, TransportError>> + Send;
fn acquire(
&self,
clutch: &[crate::mixnet::ExitNodeId],
publisher: StatusPublisher,
) -> impl Future<Output = Result<MixnetProxy, TransportError>> + Send;
fn rotation_verdict(&self) -> RotationVerdict;
}
pub(crate) enum Acquirer {
Spawned(SpawnedBinary),
Hosted(HostedProvider),
#[cfg(test)]
Announcing(AnnouncingAcquirer),
}
#[cfg(test)]
pub(crate) struct AnnouncingAcquirer {
pub(crate) socks5_addr: std::net::SocketAddr,
pub(crate) census: Vec<crate::mixnet::ExitNodeId>,
pub(crate) yields: usize,
pub(crate) rotation: RotationVerdict,
}
#[cfg(test)]
impl TransportAcquirable for AnnouncingAcquirer {
async fn discover(&self) -> Result<HashSet<crate::mixnet::ExitNodeId>, TransportError> {
Ok(self.census.iter().cloned().collect())
}
async fn acquire(
&self,
clutch: &[crate::mixnet::ExitNodeId],
publisher: StatusPublisher,
) -> Result<MixnetProxy, TransportError> {
let addr = self.socks5_addr;
let clutch = clutch.to_vec();
let proxy = MixnetProxy::attach(addr, &clutch, std::sync::Arc::clone(&publisher))?;
publisher.send_replace(crate::mixnet::MixnetStatus {
mode: crate::mixnet::Indicator::Ready,
socks5_addr: Some(addr),
exits: clutch,
bootstrap_detail: None,
death: None,
});
for _ in 0..self.yields {
tokio::task::yield_now().await;
}
Ok(proxy)
}
fn rotation_verdict(&self) -> RotationVerdict {
self.rotation
}
}
impl TransportAcquirable for Acquirer {
async fn discover(&self) -> Result<HashSet<crate::mixnet::ExitNodeId>, TransportError> {
match self {
Acquirer::Spawned(spawned) => spawned.discover().await,
Acquirer::Hosted(hosted) => hosted.discover().await,
#[cfg(test)]
Acquirer::Announcing(announcing) => announcing.discover().await,
}
}
async fn acquire(
&self,
clutch: &[crate::mixnet::ExitNodeId],
publisher: StatusPublisher,
) -> Result<MixnetProxy, TransportError> {
match self {
Acquirer::Spawned(spawned) => spawned.acquire(clutch, publisher).await,
Acquirer::Hosted(hosted) => hosted.acquire(clutch, publisher).await,
#[cfg(test)]
Acquirer::Announcing(announcing) => announcing.acquire(clutch, publisher).await,
}
}
fn rotation_verdict(&self) -> RotationVerdict {
match self {
Acquirer::Spawned(spawned) => spawned.rotation_verdict(),
Acquirer::Hosted(hosted) => {
<HostedProvider as TransportAcquirable>::rotation_verdict(hosted)
}
#[cfg(test)]
Acquirer::Announcing(announcing) => announcing.rotation_verdict(),
}
}
}
pub use zingo_netutils::provider::{
HostRefusal, HostedProvider, HostedTransport, ProxyHosting, RotationVerdict, rotation_interval,
};
impl TransportAcquirable for HostedProvider {
async fn discover(&self) -> Result<HashSet<crate::mixnet::ExitNodeId>, TransportError> {
let provider = self.clone();
let reported = tokio::task::spawn_blocking(move || provider.discover_exit_nodes())
.await
.map_err(|join| TransportError::HostUnavailable(join.to_string()))?
.map_err(TransportError::HostRefused)?;
Ok(reported.into_iter().collect())
}
async fn acquire(
&self,
clutch: &[crate::mixnet::ExitNodeId],
publisher: StatusPublisher,
) -> Result<MixnetProxy, TransportError> {
let provider = self.clone();
let clutch = clutch.to_vec();
let hosted = tokio::task::spawn_blocking(move || provider.start_transport(&clutch))
.await
.map_err(|join| TransportError::HostUnavailable(join.to_string()))?
.map_err(TransportError::HostRefused)?;
MixnetProxy::attach(hosted.socks5_addr, &[hosted.exit_node], publisher)
.map_err(TransportError::from)
}
fn rotation_verdict(&self) -> RotationVerdict {
zingo_netutils::provider::HostedProvider::rotation_verdict(self)
}
}
pub(crate) struct SpawnedBinary {
path: PathBuf,
}
impl SpawnedBinary {
pub(crate) fn at(path: PathBuf) -> Self {
SpawnedBinary { path }
}
}
impl TransportAcquirable for SpawnedBinary {
async fn discover(&self) -> Result<HashSet<crate::mixnet::ExitNodeId>, TransportError> {
crate::mixnet::supervisor::discover_exit_nodes(&self.path).await
}
async fn acquire(
&self,
clutch: &[crate::mixnet::ExitNodeId],
publisher: StatusPublisher,
) -> Result<MixnetProxy, TransportError> {
MixnetProxy::spawn(&self.path, publisher, clutch).map_err(TransportError::from)
}
fn rotation_verdict(&self) -> RotationVerdict {
RotationVerdict::Never
}
}
#[cfg(test)]
mod tests {
use super::*;
const DETAIL_RENDERINGS: usize = 1;
#[test]
fn a_discovery_spawn_failure_renders_its_detail_once() {
const DETAIL: &str = "the discover binary is absent";
let failure = TransportError::DiscoverySpawn(std::io::Error::other(DETAIL));
let chain = zingo_net_diag::chain_texts(&failure);
assert_eq!(
chain.join("\n").matches(DETAIL).count(),
DETAIL_RENDERINGS,
"the chain rendered was {chain:?}"
);
}
struct ScriptedHost {
directory: Result<Vec<crate::mixnet::ExitNodeId>, HostRefusal>,
transport: Result<HostedTransport, HostRefusal>,
}
impl ProxyHosting for ScriptedHost {
fn discover_exit_nodes(&self) -> Result<Vec<crate::mixnet::ExitNodeId>, HostRefusal> {
self.directory.clone()
}
fn start_transport(
&self,
_clutch: &[crate::mixnet::ExitNodeId],
) -> Result<HostedTransport, HostRefusal> {
self.transport.clone()
}
fn rotation_verdict(&self) -> zingo_netutils::provider::RotationVerdict {
zingo_netutils::provider::RotationVerdict::Defer(
zingo_netutils::time::CLIENT_ROTATION_MAX,
)
}
}
fn hosted(host: ScriptedHost) -> HostedProvider {
HostedProvider::owned_by(host)
}
#[tokio::test]
async fn a_host_directory_answers_discovery() {
let acquirer = hosted(ScriptedHost {
directory: Ok(vec!["exit-a".into(), "exit-b".into()]),
transport: Err(HostRefusal::Declined {
detail: "unused".to_string(),
}),
});
assert_eq!(
acquirer.discover().await.expect("the host answers"),
HashSet::from([
crate::mixnet::ExitNodeId::from("exit-a"),
crate::mixnet::ExitNodeId::from("exit-b")
])
);
}
const DISTINCT_DISCOVERED: usize = 2;
#[tokio::test]
async fn a_duplicated_host_directory_discovers_once() {
let acquirer = hosted(ScriptedHost {
directory: Ok(vec!["exit-a".into(), "exit-b".into(), "exit-a".into()]),
transport: Err(HostRefusal::Declined {
detail: "unused".to_string(),
}),
});
assert_eq!(
acquirer.discover().await.expect("the host answers").len(),
DISTINCT_DISCOVERED,
"the repeated identity is one Exit Node"
);
}
#[tokio::test]
async fn a_refusing_host_refuses_typed() {
let acquirer = hosted(ScriptedHost {
directory: Err(HostRefusal::Declined {
detail: "no directory on this mobile platform".to_string(),
}),
transport: Err(HostRefusal::Declined {
detail: "the app declined".to_string(),
}),
});
assert!(matches!(
acquirer.discover().await.expect_err("the host refuses"),
TransportError::HostRefused(_)
));
let Err(refusal) = acquirer
.acquire(&["exit-a".into()], crate::mixnet::status_publisher())
.await
else {
panic!("a declining host must not yield a transport");
};
assert!(matches!(refusal, TransportError::HostRefused(_)));
}
#[tokio::test]
async fn a_typed_host_endpoint_reaches_the_attach_seam() {
let acquirer = hosted(ScriptedHost {
directory: Ok(Vec::new()),
transport: Ok(HostedTransport {
socks5_addr: "127.0.0.1:1080".parse().expect("the test address parses"),
exit_node: "exit-a".into(),
}),
});
let proxy = acquirer
.acquire(&["exit-a".into()], crate::mixnet::status_publisher())
.await
.expect("a typed endpoint always constructs the attached transport");
proxy.stop().await;
}
}