fix private routing 1.0
This commit is contained in:
parent
da9276a77f
commit
28c31fe424
@ -126,14 +126,18 @@ struct RouteHop @0xf8f672d75cce0c3b {
|
|||||||
nodeId @0 :NodeID; # node id only for established routes
|
nodeId @0 :NodeID; # node id only for established routes
|
||||||
peerInfo @1 :PeerInfo; # full peer info for this hop to establish the route
|
peerInfo @1 :PeerInfo; # full peer info for this hop to establish the route
|
||||||
}
|
}
|
||||||
nextHop @2 :RouteHopData; # Optional: if the private route is a stub, it contains no route hop data, just the target node for the routed operation.
|
nextHop @2 :RouteHopData; # optional: If this the end of a private route, this field will not exist
|
||||||
# if this is a safety route routehop, this field is not optional and must exist
|
# if this is a safety route routehop, this field is not optional and must exist
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PrivateRoute @0x8a83fccb0851e776 {
|
struct PrivateRoute @0x8a83fccb0851e776 {
|
||||||
publicKey @0 :RoutePublicKey; # private route public key (unique per private route)
|
publicKey @0 :RoutePublicKey; # private route public key (unique per private route)
|
||||||
hopCount @1 :UInt8; # Count of hops left in the private route (for timeout calculation purposes only)
|
hopCount @1 :UInt8; # Count of hops left in the private route (for timeout calculation purposes only)
|
||||||
firstHop @2 :RouteHop; # Optional: first hop in the private route, if empty, this is the last hop and payload should be decrypted and processed.
|
hops :union {
|
||||||
|
firstHop @2 :RouteHop; # first hop of a private route is unencrypted (hopcount > 0)
|
||||||
|
data @3 :RouteHopData; # private route has more hops (hopcount > 0 && hopcount < total_hopcount)
|
||||||
|
empty @4 :Void; # private route has ended (hopcount = 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SafetyRoute @0xf554734d07cb5d59 {
|
struct SafetyRoute @0xf554734d07cb5d59 {
|
||||||
|
@ -854,8 +854,8 @@ impl RouteSpecStore {
|
|||||||
if pr_hopcount > max_route_hop_count {
|
if pr_hopcount > max_route_hop_count {
|
||||||
bail!("private route hop count too long");
|
bail!("private route hop count too long");
|
||||||
}
|
}
|
||||||
let Some(pr_first_hop) = &private_route.first_hop else {
|
let PrivateRouteHops::FirstHop(pr_first_hop) = &private_route.hops else {
|
||||||
bail!("compiled private route should have first_hop");
|
bail!("compiled private route should have first hop");
|
||||||
};
|
};
|
||||||
|
|
||||||
// See if we are using a safety route, if not, short circuit this operation
|
// See if we are using a safety route, if not, short circuit this operation
|
||||||
@ -1179,7 +1179,7 @@ impl RouteSpecStore {
|
|||||||
let private_route = PrivateRoute {
|
let private_route = PrivateRoute {
|
||||||
public_key: key.clone(),
|
public_key: key.clone(),
|
||||||
hop_count: hop_count.try_into().unwrap(),
|
hop_count: hop_count.try_into().unwrap(),
|
||||||
first_hop: Some(route_hop),
|
hops: PrivateRouteHops::FirstHop(route_hop),
|
||||||
};
|
};
|
||||||
Ok(private_route)
|
Ok(private_route)
|
||||||
}
|
}
|
||||||
|
@ -106,11 +106,20 @@ pub fn encode_private_route(
|
|||||||
&mut builder.reborrow().init_public_key(),
|
&mut builder.reborrow().init_public_key(),
|
||||||
)?;
|
)?;
|
||||||
builder.set_hop_count(private_route.hop_count);
|
builder.set_hop_count(private_route.hop_count);
|
||||||
if let Some(rh) = &private_route.first_hop {
|
let mut h_builder = builder.reborrow().init_hops();
|
||||||
let mut rh_builder = builder.reborrow().init_first_hop();
|
match &private_route.hops {
|
||||||
encode_route_hop(rh, &mut rh_builder)?;
|
PrivateRouteHops::FirstHop(first_hop) => {
|
||||||
|
let mut rh_builder = h_builder.init_first_hop();
|
||||||
|
encode_route_hop(first_hop, &mut rh_builder)?;
|
||||||
|
}
|
||||||
|
PrivateRouteHops::Data(data) => {
|
||||||
|
let mut rhd_builder = h_builder.init_data();
|
||||||
|
encode_route_hop_data(data, &mut rhd_builder)?;
|
||||||
|
}
|
||||||
|
PrivateRouteHops::Empty => {
|
||||||
|
h_builder.set_empty(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,19 +130,23 @@ pub fn decode_private_route(
|
|||||||
"invalid public key in private route",
|
"invalid public key in private route",
|
||||||
))?);
|
))?);
|
||||||
let hop_count = reader.get_hop_count();
|
let hop_count = reader.get_hop_count();
|
||||||
let first_hop = if reader.has_first_hop() {
|
|
||||||
let rh_reader = reader
|
let hops = match reader.get_hops().which().map_err(RPCError::protocol)? {
|
||||||
.get_first_hop()
|
veilid_capnp::private_route::hops::Which::FirstHop(rh_reader) => {
|
||||||
.map_err(RPCError::map_protocol("invalid first hop in private route"))?;
|
let rh_reader = rh_reader.map_err(RPCError::protocol)?;
|
||||||
Some(decode_route_hop(&rh_reader)?)
|
PrivateRouteHops::FirstHop(decode_route_hop(&rh_reader)?)
|
||||||
} else {
|
}
|
||||||
None
|
veilid_capnp::private_route::hops::Which::Data(rhd_reader) => {
|
||||||
|
let rhd_reader = rhd_reader.map_err(RPCError::protocol)?;
|
||||||
|
PrivateRouteHops::Data(decode_route_hop_data(&rhd_reader)?)
|
||||||
|
}
|
||||||
|
veilid_capnp::private_route::hops::Which::Empty(_) => PrivateRouteHops::Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(PrivateRoute {
|
Ok(PrivateRoute {
|
||||||
public_key,
|
public_key,
|
||||||
hop_count,
|
hop_count,
|
||||||
first_hop,
|
hops,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -205,8 +205,8 @@ impl RPCProcessor {
|
|||||||
private_route,
|
private_route,
|
||||||
safety_selection,
|
safety_selection,
|
||||||
} => {
|
} => {
|
||||||
let Some(pr_first_hop) = &private_route.first_hop else {
|
let PrivateRouteHops::FirstHop(pr_first_hop) = &private_route.hops else {
|
||||||
return Err(RPCError::internal("destination private route must have first_hop"));
|
return Err(RPCError::internal("destination private route must have first hop"));
|
||||||
};
|
};
|
||||||
|
|
||||||
match safety_selection {
|
match safety_selection {
|
||||||
|
@ -72,26 +72,20 @@ impl RPCProcessor {
|
|||||||
#[instrument(level = "trace", skip_all, err)]
|
#[instrument(level = "trace", skip_all, err)]
|
||||||
async fn process_route_private_route_hop(
|
async fn process_route_private_route_hop(
|
||||||
&self,
|
&self,
|
||||||
mut route: RPCOperationRoute,
|
mut routed_operation: RoutedOperation,
|
||||||
mut next_private_route: PrivateRoute,
|
next_route_node: RouteNode,
|
||||||
|
safety_route_public_key: DHTKey,
|
||||||
|
next_private_route: PrivateRoute,
|
||||||
) -> Result<(), RPCError> {
|
) -> Result<(), RPCError> {
|
||||||
// Make sure hop count makes sense
|
// Make sure hop count makes sense
|
||||||
if route.safety_route.hop_count != 0 {
|
|
||||||
return Err(RPCError::protocol(
|
|
||||||
"Safety hop count should be zero if switched to private route",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if next_private_route.hop_count as usize > self.unlocked_inner.max_route_hop_count {
|
if next_private_route.hop_count as usize > self.unlocked_inner.max_route_hop_count {
|
||||||
return Err(RPCError::protocol(
|
return Err(RPCError::protocol(
|
||||||
"Private route hop count too high to process",
|
"Private route hop count too high to process",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get private route first hop (this is validated to not be None before calling this function)
|
|
||||||
let first_hop = next_private_route.first_hop.as_ref().unwrap();
|
|
||||||
|
|
||||||
// Get next hop node ref
|
// Get next hop node ref
|
||||||
let next_hop_nr = match &first_hop.node {
|
let next_hop_nr = match &next_route_node {
|
||||||
RouteNode::NodeId(id) => {
|
RouteNode::NodeId(id) => {
|
||||||
//
|
//
|
||||||
self.routing_table
|
self.routing_table
|
||||||
@ -116,27 +110,24 @@ impl RPCProcessor {
|
|||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
if first_hop.next_hop.is_some() {
|
if !matches!(next_private_route.hops, PrivateRouteHops::Empty) {
|
||||||
// Sign the operation if this is not our last hop
|
// Sign the operation if this is not our last hop
|
||||||
// as the last hop is already signed by the envelope
|
// as the last hop is already signed by the envelope
|
||||||
let node_id = self.routing_table.node_id();
|
let node_id = self.routing_table.node_id();
|
||||||
let node_id_secret = self.routing_table.node_id_secret();
|
let node_id_secret = self.routing_table.node_id_secret();
|
||||||
let sig = sign(&node_id, &node_id_secret, &route.operation.data)
|
let sig = sign(&node_id, &node_id_secret, &routed_operation.data)
|
||||||
.map_err(RPCError::internal)?;
|
.map_err(RPCError::internal)?;
|
||||||
route.operation.signatures.push(sig);
|
routed_operation.signatures.push(sig);
|
||||||
} else {
|
|
||||||
// If this is our last hop, then we drop the 'first_hop' from private route
|
|
||||||
// XXX ? next_private_route.first_hop = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass along the route
|
// Pass along the route
|
||||||
let next_hop_route = RPCOperationRoute {
|
let next_hop_route = RPCOperationRoute {
|
||||||
safety_route: SafetyRoute {
|
safety_route: SafetyRoute {
|
||||||
public_key: route.safety_route.public_key,
|
public_key: safety_route_public_key,
|
||||||
hop_count: 0,
|
hop_count: 0,
|
||||||
hops: SafetyRouteHops::Private(next_private_route),
|
hops: SafetyRouteHops::Private(next_private_route),
|
||||||
},
|
},
|
||||||
operation: route.operation,
|
operation: routed_operation,
|
||||||
};
|
};
|
||||||
let next_hop_route_stmt = RPCStatement::new(RPCStatementDetail::Route(next_hop_route));
|
let next_hop_route_stmt = RPCStatement::new(RPCStatementDetail::Route(next_hop_route));
|
||||||
|
|
||||||
@ -342,19 +333,25 @@ impl RPCProcessor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the next hop node ref
|
// Get the next hop node ref
|
||||||
if private_route.first_hop.is_some() {
|
let PrivateRouteHops::FirstHop(pr_first_hop) = private_route.hops else {
|
||||||
// Switching to private route from safety route
|
return Err(RPCError::protocol("switching from safety route to private route requires first hop"));
|
||||||
self.process_route_private_route_hop(route, private_route)
|
};
|
||||||
.await?;
|
|
||||||
} else {
|
// Switching to private route from safety route
|
||||||
// Private route is empty, process routed operation
|
self.process_route_private_route_hop(
|
||||||
self.process_routed_operation(
|
route.operation,
|
||||||
detail,
|
pr_first_hop.node,
|
||||||
route.operation,
|
route.safety_route.public_key,
|
||||||
&route.safety_route,
|
PrivateRoute {
|
||||||
&private_route,
|
public_key: private_route.public_key,
|
||||||
)?;
|
hop_count: private_route.hop_count - 1,
|
||||||
}
|
hops: pr_first_hop
|
||||||
|
.next_hop
|
||||||
|
.map(|rhd| PrivateRouteHops::Data(rhd))
|
||||||
|
.unwrap_or(PrivateRouteHops::Empty),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
} else if blob_tag == 0 {
|
} else if blob_tag == 0 {
|
||||||
// RouteHop
|
// RouteHop
|
||||||
let route_hop = {
|
let route_hop = {
|
||||||
@ -373,18 +370,24 @@ impl RPCProcessor {
|
|||||||
// No safety route left, now doing private route
|
// No safety route left, now doing private route
|
||||||
SafetyRouteHops::Private(ref private_route) => {
|
SafetyRouteHops::Private(ref private_route) => {
|
||||||
// See if we have a hop, if not, we are at the end of the private route
|
// See if we have a hop, if not, we are at the end of the private route
|
||||||
if let Some(first_hop) = &private_route.first_hop {
|
match &private_route.hops {
|
||||||
// See if we have next hop data
|
PrivateRouteHops::FirstHop(_) => {
|
||||||
let opt_next_first_hop = if let Some(next_hop) = &first_hop.next_hop {
|
return Err(RPCError::protocol("should not have first hop here"));
|
||||||
|
}
|
||||||
|
PrivateRouteHops::Data(route_hop_data) => {
|
||||||
// Decrypt the blob with DEC(nonce, DH(the PR's public key, this hop's secret)
|
// Decrypt the blob with DEC(nonce, DH(the PR's public key, this hop's secret)
|
||||||
let node_id_secret = self.routing_table.node_id_secret();
|
let node_id_secret = self.routing_table.node_id_secret();
|
||||||
let dh_secret = self
|
let dh_secret = self
|
||||||
.crypto
|
.crypto
|
||||||
.cached_dh(&private_route.public_key, &node_id_secret)
|
.cached_dh(&private_route.public_key, &node_id_secret)
|
||||||
.map_err(RPCError::protocol)?;
|
.map_err(RPCError::protocol)?;
|
||||||
let dec_blob_data =
|
let dec_blob_data = Crypto::decrypt_aead(
|
||||||
Crypto::decrypt_aead(&next_hop.blob, &next_hop.nonce, &dh_secret, None)
|
&route_hop_data.blob,
|
||||||
.map_err(RPCError::protocol)?;
|
&route_hop_data.nonce,
|
||||||
|
&dh_secret,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(RPCError::protocol)?;
|
||||||
let dec_blob_reader = RPCMessageData::new(dec_blob_data).get_reader()?;
|
let dec_blob_reader = RPCMessageData::new(dec_blob_data).get_reader()?;
|
||||||
|
|
||||||
// Decode next RouteHop
|
// Decode next RouteHop
|
||||||
@ -394,40 +397,42 @@ impl RPCProcessor {
|
|||||||
.map_err(RPCError::protocol)?;
|
.map_err(RPCError::protocol)?;
|
||||||
decode_route_hop(&rh_reader)?
|
decode_route_hop(&rh_reader)?
|
||||||
};
|
};
|
||||||
Some(route_hop)
|
|
||||||
} else {
|
|
||||||
// If the first hop has no RouteHopData, then this is a stub private route
|
|
||||||
// and we should just pass the operation to its final destination with
|
|
||||||
// an empty safety and private route
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure hop count > 0
|
// Ensure hop count > 0
|
||||||
if private_route.hop_count == 0 {
|
if private_route.hop_count == 0 {
|
||||||
return Err(RPCError::protocol("route should not be at the end"));
|
return Err(RPCError::protocol("route should not be at the end"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make next PrivateRoute and pass it on
|
// Make next PrivateRoute and pass it on
|
||||||
let next_private_route = PrivateRoute {
|
self.process_route_private_route_hop(
|
||||||
public_key: private_route.public_key,
|
route.operation,
|
||||||
hop_count: private_route.hop_count - 1,
|
route_hop.node,
|
||||||
first_hop: opt_next_first_hop,
|
route.safety_route.public_key,
|
||||||
};
|
PrivateRoute {
|
||||||
self.process_route_private_route_hop(route, next_private_route)
|
public_key: private_route.public_key,
|
||||||
|
hop_count: private_route.hop_count - 1,
|
||||||
|
hops: route_hop
|
||||||
|
.next_hop
|
||||||
|
.map(|rhd| PrivateRouteHops::Data(rhd))
|
||||||
|
.unwrap_or(PrivateRouteHops::Empty),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
|
||||||
// Ensure hop count == 0
|
|
||||||
if private_route.hop_count != 0 {
|
|
||||||
return Err(RPCError::protocol("route should be at the end"));
|
|
||||||
}
|
}
|
||||||
|
PrivateRouteHops::Empty => {
|
||||||
|
// Ensure hop count == 0
|
||||||
|
if private_route.hop_count != 0 {
|
||||||
|
return Err(RPCError::protocol("route should be at the end"));
|
||||||
|
}
|
||||||
|
|
||||||
// No hops left, time to process the routed operation
|
// No hops left, time to process the routed operation
|
||||||
self.process_routed_operation(
|
self.process_routed_operation(
|
||||||
detail,
|
detail,
|
||||||
route.operation,
|
route.operation,
|
||||||
&route.safety_route,
|
&route.safety_route,
|
||||||
private_route,
|
private_route,
|
||||||
)?;
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,15 +3,21 @@ use super::*;
|
|||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// Compiled Privacy Objects
|
// Compiled Privacy Objects
|
||||||
|
|
||||||
|
/// An encrypted private/safety route hop
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RouteHopData {
|
pub struct RouteHopData {
|
||||||
|
/// The nonce used in the encryption ENC(Xn,DH(PKn,SKapr))
|
||||||
pub nonce: Nonce,
|
pub nonce: Nonce,
|
||||||
|
/// The encrypted blob
|
||||||
pub blob: Vec<u8>,
|
pub blob: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How to find a route node
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum RouteNode {
|
pub enum RouteNode {
|
||||||
|
/// Route node is optimized, no contact method information as this node id has been seen before
|
||||||
NodeId(NodeId),
|
NodeId(NodeId),
|
||||||
|
/// Route node with full contact method information to ensure the peer is reachable
|
||||||
PeerInfo(PeerInfo),
|
PeerInfo(PeerInfo),
|
||||||
}
|
}
|
||||||
impl fmt::Display for RouteNode {
|
impl fmt::Display for RouteNode {
|
||||||
@ -27,17 +33,33 @@ impl fmt::Display for RouteNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An unencrypted private/safety route hop
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RouteHop {
|
pub struct RouteHop {
|
||||||
|
/// The location of the hop
|
||||||
pub node: RouteNode,
|
pub node: RouteNode,
|
||||||
|
/// The encrypted blob to pass to the next hop as its data (None for stubs)
|
||||||
pub next_hop: Option<RouteHopData>,
|
pub next_hop: Option<RouteHopData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind of hops a private route can have
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum PrivateRouteHops {
|
||||||
|
/// The first hop of a private route, unencrypted, route_hops == total hop count
|
||||||
|
FirstHop(RouteHop),
|
||||||
|
/// Private route internal node. Has > 0 private route hops left but < total hop count
|
||||||
|
Data(RouteHopData),
|
||||||
|
/// Private route has ended (hop count = 0)
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A private route for receiver privacy
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PrivateRoute {
|
pub struct PrivateRoute {
|
||||||
|
/// The public key used for the entire route
|
||||||
pub public_key: DHTKey,
|
pub public_key: DHTKey,
|
||||||
pub hop_count: u8,
|
pub hop_count: u8,
|
||||||
pub first_hop: Option<RouteHop>,
|
pub hops: PrivateRouteHops,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrivateRoute {
|
impl PrivateRoute {
|
||||||
@ -46,7 +68,7 @@ impl PrivateRoute {
|
|||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
hop_count: 0,
|
hop_count: 0,
|
||||||
first_hop: None,
|
hops: PrivateRouteHops::Empty,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Stub route is the form used when no privacy is required, but you need to specify the destination for a safety route
|
/// Stub route is the form used when no privacy is required, but you need to specify the destination for a safety route
|
||||||
@ -54,29 +76,12 @@ impl PrivateRoute {
|
|||||||
Self {
|
Self {
|
||||||
public_key,
|
public_key,
|
||||||
hop_count: 1,
|
hop_count: 1,
|
||||||
first_hop: Some(RouteHop {
|
hops: PrivateRouteHops::FirstHop(RouteHop {
|
||||||
node,
|
node,
|
||||||
next_hop: None,
|
next_hop: None,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Switch from full node info to simple node id
|
|
||||||
/// Only simplified single hop, primarily useful for stubs
|
|
||||||
/// Published routes with >= 1 hops should be in NodeId form already, as they have
|
|
||||||
/// already been connectivity-verified by the time the route is published
|
|
||||||
pub fn simplify(self) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key: self.public_key,
|
|
||||||
hop_count: self.hop_count,
|
|
||||||
first_hop: self.first_hop.map(|h| RouteHop {
|
|
||||||
node: match h.node {
|
|
||||||
RouteNode::NodeId(ni) => RouteNode::NodeId(ni),
|
|
||||||
RouteNode::PeerInfo(pi) => RouteNode::NodeId(pi.node_id),
|
|
||||||
},
|
|
||||||
next_hop: h.next_hop,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PrivateRoute {
|
impl fmt::Display for PrivateRoute {
|
||||||
@ -86,10 +91,16 @@ impl fmt::Display for PrivateRoute {
|
|||||||
"PR({:?}+{}{})",
|
"PR({:?}+{}{})",
|
||||||
self.public_key,
|
self.public_key,
|
||||||
self.hop_count,
|
self.hop_count,
|
||||||
if let Some(first_hop) = &self.first_hop {
|
match &self.hops {
|
||||||
format!("->{}", first_hop.node)
|
PrivateRouteHops::FirstHop(fh) => {
|
||||||
} else {
|
format!("->{}", fh.node)
|
||||||
"".to_owned()
|
}
|
||||||
|
PrivateRouteHops::Data(_) => {
|
||||||
|
"->?".to_owned()
|
||||||
|
}
|
||||||
|
PrivateRouteHops::Empty => {
|
||||||
|
"".to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user