2018-10-02 07:35:03 +00:00
|
|
|
extern crate rayon;
|
|
|
|
|
|
|
|
use self::rayon::prelude::*;
|
|
|
|
|
|
|
|
use std::sync::{
|
|
|
|
Arc,
|
|
|
|
RwLock,
|
|
|
|
};
|
|
|
|
use super::attestation_validation::{
|
|
|
|
AttestationValidationContext,
|
|
|
|
AttestationValidationError,
|
|
|
|
};
|
|
|
|
use super::types::{
|
|
|
|
AttestationRecord,
|
|
|
|
AttesterMap,
|
2018-10-16 02:59:45 +00:00
|
|
|
BeaconBlock,
|
2018-10-02 07:35:03 +00:00
|
|
|
ProposerMap,
|
|
|
|
};
|
|
|
|
use super::ssz_helpers::attestation_ssz_splitter::{
|
|
|
|
split_one_attestation,
|
|
|
|
split_all_attestations,
|
|
|
|
AttestationSplitError,
|
|
|
|
};
|
2018-10-16 02:59:45 +00:00
|
|
|
use super::ssz_helpers::ssz_beacon_block::{
|
|
|
|
SszBeaconBlock,
|
|
|
|
SszBeaconBlockError,
|
2018-10-02 07:35:03 +00:00
|
|
|
};
|
|
|
|
use super::db::{
|
|
|
|
ClientDB,
|
|
|
|
DBError,
|
|
|
|
};
|
|
|
|
use super::db::stores::{
|
2018-10-16 02:59:45 +00:00
|
|
|
BeaconBlockStore,
|
2018-10-02 07:35:03 +00:00
|
|
|
PoWChainStore,
|
|
|
|
ValidatorStore,
|
|
|
|
};
|
|
|
|
use super::ssz::{
|
|
|
|
Decodable,
|
|
|
|
DecodeError,
|
|
|
|
};
|
|
|
|
use super::types::Hash256;
|
|
|
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
2018-10-16 02:59:45 +00:00
|
|
|
pub enum BeaconBlockStatus {
|
2018-10-02 07:35:03 +00:00
|
|
|
NewBlock,
|
|
|
|
KnownBlock,
|
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
2018-10-16 02:59:45 +00:00
|
|
|
pub enum SszBeaconBlockValidationError {
|
2018-10-02 07:35:03 +00:00
|
|
|
FutureSlot,
|
|
|
|
SlotAlreadyFinalized,
|
|
|
|
UnknownPoWChainRef,
|
|
|
|
UnknownParentHash,
|
|
|
|
BadAttestationSsz,
|
2018-10-16 02:59:45 +00:00
|
|
|
BadAncestorHashesSsz,
|
|
|
|
BadSpecialsSsz,
|
2018-10-12 09:54:33 +00:00
|
|
|
ParentSlotHigherThanBlockSlot,
|
2018-10-02 07:35:03 +00:00
|
|
|
AttestationValidationError(AttestationValidationError),
|
|
|
|
AttestationSignatureFailed,
|
|
|
|
ProposerAttestationHasObliqueHashes,
|
|
|
|
NoProposerSignature,
|
|
|
|
BadProposerMap,
|
|
|
|
RwLockPoisoned,
|
|
|
|
DBError(String),
|
|
|
|
}
|
|
|
|
|
|
|
|
/// The context against which a block should be validated.
|
2018-10-16 02:59:45 +00:00
|
|
|
pub struct BeaconBlockValidationContext<T>
|
2018-10-02 07:35:03 +00:00
|
|
|
where T: ClientDB + Sized
|
|
|
|
{
|
|
|
|
/// The slot as determined by the system time.
|
|
|
|
pub present_slot: u64,
|
|
|
|
/// The cycle_length as determined by the chain configuration.
|
|
|
|
pub cycle_length: u8,
|
|
|
|
/// The last justified slot as per the client's view of the canonical chain.
|
|
|
|
pub last_justified_slot: u64,
|
2018-10-09 01:14:59 +00:00
|
|
|
/// The last justified block hash as per the client's view of the canonical chain.
|
|
|
|
pub last_justified_block_hash: Hash256,
|
2018-10-02 07:35:03 +00:00
|
|
|
/// The last finalized slot as per the client's view of the canonical chain.
|
|
|
|
pub last_finalized_slot: u64,
|
|
|
|
/// A vec of the hashes of the blocks preceeding the present slot.
|
|
|
|
pub parent_hashes: Arc<Vec<Hash256>>,
|
|
|
|
/// A map of slots to a block proposer validation index.
|
|
|
|
pub proposer_map: Arc<ProposerMap>,
|
|
|
|
/// A map of (slot, shard_id) to the attestation set of validation indices.
|
|
|
|
pub attester_map: Arc<AttesterMap>,
|
|
|
|
/// The store containing block information.
|
2018-10-16 02:59:45 +00:00
|
|
|
pub block_store: Arc<BeaconBlockStore<T>>,
|
2018-10-02 07:35:03 +00:00
|
|
|
/// The store containing validator information.
|
|
|
|
pub validator_store: Arc<ValidatorStore<T>>,
|
|
|
|
/// The store containing information about the proof-of-work chain.
|
|
|
|
pub pow_store: Arc<PoWChainStore<T>>,
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl<T> BeaconBlockValidationContext<T>
|
2018-10-02 07:35:03 +00:00
|
|
|
where T: ClientDB
|
|
|
|
{
|
2018-10-16 02:59:45 +00:00
|
|
|
/// Validate some SszBeaconBlock against a block validation context. An SszBeaconBlock varies from a BeaconBlock in
|
2018-10-02 07:35:03 +00:00
|
|
|
/// that is a read-only structure that reads directly from encoded SSZ.
|
|
|
|
///
|
2018-10-16 02:59:45 +00:00
|
|
|
/// The reason to validate an SzzBeaconBlock is to avoid decoding it in its entirety if there is
|
2018-10-02 07:35:03 +00:00
|
|
|
/// a suspicion that the block might be invalid. Such a suspicion should be applied to
|
|
|
|
/// all blocks coming from the network.
|
|
|
|
///
|
|
|
|
/// This function will determine if the block is new, already known or invalid (either
|
|
|
|
/// intrinsically or due to some application error.)
|
|
|
|
///
|
|
|
|
/// Note: this function does not implement randao_reveal checking as it is not in the
|
|
|
|
/// specification.
|
|
|
|
#[allow(dead_code)]
|
2018-10-16 02:59:45 +00:00
|
|
|
pub fn validate_ssz_block(&self, b: &SszBeaconBlock)
|
|
|
|
-> Result<(BeaconBlockStatus, Option<BeaconBlock>), SszBeaconBlockValidationError>
|
2018-10-02 07:35:03 +00:00
|
|
|
where T: ClientDB + Sized
|
|
|
|
{
|
|
|
|
|
|
|
|
/*
|
|
|
|
* If this block is already known, return immediately and indicate the the block is
|
|
|
|
* known. Don't attempt to deserialize the block.
|
|
|
|
*/
|
|
|
|
let block_hash = &b.block_hash();
|
|
|
|
if self.block_store.block_exists(&block_hash)? {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Ok((BeaconBlockStatus::KnownBlock, None));
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
2018-10-04 04:46:05 +00:00
|
|
|
* If the block slot corresponds to a slot in the future, return immediately with an error.
|
|
|
|
*
|
|
|
|
* It is up to the calling fn to determine what should be done with "future" blocks (e.g.,
|
|
|
|
* cache or discard).
|
2018-10-02 07:35:03 +00:00
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let block_slot = b.slot();
|
2018-10-02 07:35:03 +00:00
|
|
|
if block_slot > self.present_slot {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Err(SszBeaconBlockValidationError::FutureSlot);
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* If the block is unknown (assumed unknown because we checked the db earlier in this
|
|
|
|
* function) and it comes from a slot that is already finalized, drop the block.
|
|
|
|
*
|
|
|
|
* If a slot is finalized, there's no point in considering any other blocks for that slot.
|
2018-10-12 08:30:52 +00:00
|
|
|
*
|
|
|
|
* TODO: We can more strongly throw away blocks based on the `last_finalized_block` related
|
|
|
|
* to this `last_finalized_slot`. Namely, any block in a future slot must include the
|
|
|
|
* `last_finalized_block` in it's chain.
|
2018-10-02 07:35:03 +00:00
|
|
|
*/
|
|
|
|
if block_slot <= self.last_finalized_slot {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Err(SszBeaconBlockValidationError::SlotAlreadyFinalized);
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* If the PoW chain hash is not known to us, drop it.
|
|
|
|
*
|
|
|
|
* We only accept blocks that reference a known PoW hash.
|
|
|
|
*
|
|
|
|
* Note: it is not clear what a "known" PoW chain ref is. Likely it means the block hash is
|
|
|
|
* "sufficienty deep in the canonical PoW chain". This should be clarified as the spec
|
|
|
|
* crystallizes.
|
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let pow_chain_reference = b.pow_chain_reference();
|
|
|
|
if !self.pow_store.block_hash_exists(b.pow_chain_reference())? {
|
|
|
|
return Err(SszBeaconBlockValidationError::UnknownPoWChainRef);
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Store a slice of the serialized attestations from the block SSZ.
|
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let attestations_ssz = &b.attestations_without_length();
|
2018-10-02 07:35:03 +00:00
|
|
|
|
|
|
|
/*
|
|
|
|
* Get a slice of the first serialized attestation (the 0'th) and decode it into
|
|
|
|
* a full AttestationRecord object.
|
|
|
|
*
|
|
|
|
* The first attestation must be validated separately as it must contain a signature of the
|
|
|
|
* proposer of the previous block (this is checked later in this function).
|
|
|
|
*/
|
|
|
|
let (first_attestation_ssz, next_index) = split_one_attestation(
|
|
|
|
&attestations_ssz,
|
|
|
|
0)?;
|
|
|
|
let (first_attestation, _) = AttestationRecord::ssz_decode(
|
|
|
|
&first_attestation_ssz, 0)?;
|
|
|
|
|
|
|
|
/*
|
|
|
|
* The first attestation may not have oblique hashes.
|
|
|
|
*
|
|
|
|
* The presence of oblique hashes in the first attestation would indicate that the proposer
|
|
|
|
* of the previous block is attesting to some other block than the one they produced.
|
|
|
|
*/
|
|
|
|
if first_attestation.oblique_parent_hashes.len() > 0 {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Err(SszBeaconBlockValidationError::ProposerAttestationHasObliqueHashes);
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
2018-10-04 05:43:17 +00:00
|
|
|
/*
|
|
|
|
* Read the parent hash from the block we are validating then attempt to load
|
|
|
|
* that parent block ssz from the database.
|
|
|
|
*
|
|
|
|
* If that parent doesn't exist in the database or is invalid, reject the block.
|
|
|
|
*
|
|
|
|
* Also, read the slot from the parent block for later use.
|
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let parent_hash = b.parent_hash()
|
|
|
|
.ok_or(SszBeaconBlockValidationError::BadAncestorHashesSsz)?;
|
2018-10-12 09:41:18 +00:00
|
|
|
let parent_block_slot = match self.block_store.get_serialized_block(&parent_hash)? {
|
2018-10-16 02:59:45 +00:00
|
|
|
None => return Err(SszBeaconBlockValidationError::UnknownParentHash),
|
2018-10-04 05:43:17 +00:00
|
|
|
Some(ssz) => {
|
2018-10-16 02:59:45 +00:00
|
|
|
let parent_block = SszBeaconBlock::from_slice(&ssz[..])?;
|
|
|
|
parent_block.slot()
|
2018-10-04 05:43:17 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-10-12 09:54:33 +00:00
|
|
|
/*
|
|
|
|
* The parent block slot must be less than the block slot.
|
|
|
|
*
|
|
|
|
* In other words, the parent must come before the child.
|
|
|
|
*/
|
|
|
|
if parent_block_slot >= block_slot {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Err(SszBeaconBlockValidationError::ParentSlotHigherThanBlockSlot);
|
2018-10-12 09:54:33 +00:00
|
|
|
}
|
|
|
|
|
2018-10-02 07:35:03 +00:00
|
|
|
/*
|
|
|
|
* Generate the context in which attestations will be validated.
|
|
|
|
*/
|
|
|
|
let attestation_validation_context = Arc::new(AttestationValidationContext {
|
|
|
|
block_slot,
|
2018-10-12 09:41:18 +00:00
|
|
|
parent_block_slot,
|
2018-10-02 07:35:03 +00:00
|
|
|
cycle_length: self.cycle_length,
|
|
|
|
last_justified_slot: self.last_justified_slot,
|
|
|
|
parent_hashes: self.parent_hashes.clone(),
|
2018-10-11 13:41:47 +00:00
|
|
|
block_store: self.block_store.clone(),
|
2018-10-02 07:35:03 +00:00
|
|
|
validator_store: self.validator_store.clone(),
|
|
|
|
attester_map: self.attester_map.clone(),
|
|
|
|
});
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Validate this first attestation.
|
|
|
|
*/
|
|
|
|
let attestation_voters = attestation_validation_context
|
|
|
|
.validate_attestation(&first_attestation)?;
|
|
|
|
|
|
|
|
/*
|
2018-10-04 05:43:17 +00:00
|
|
|
* Attempt to read load the parent block proposer from the proposer map. Return with an
|
|
|
|
* error if it fails.
|
2018-10-02 07:35:03 +00:00
|
|
|
*
|
2018-10-04 05:43:17 +00:00
|
|
|
* If the signature of proposer for the parent slot was not present in the first (0'th)
|
2018-10-02 07:35:03 +00:00
|
|
|
* attestation of this block, reject the block.
|
|
|
|
*/
|
2018-10-12 09:41:18 +00:00
|
|
|
let parent_block_proposer = self.proposer_map.get(&parent_block_slot)
|
2018-10-16 02:59:45 +00:00
|
|
|
.ok_or(SszBeaconBlockValidationError::BadProposerMap)?;
|
2018-10-04 05:43:17 +00:00
|
|
|
if !attestation_voters.contains(&parent_block_proposer) {
|
2018-10-16 02:59:45 +00:00
|
|
|
return Err(SszBeaconBlockValidationError::NoProposerSignature);
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Split the remaining attestations into a vector of slices, each containing
|
|
|
|
* a single serialized attestation record.
|
|
|
|
*/
|
|
|
|
let other_attestations = split_all_attestations(attestations_ssz,
|
|
|
|
next_index)?;
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Verify each other AttestationRecord.
|
|
|
|
*
|
|
|
|
* This uses the `rayon` library to do "sometimes" parallelization. Put simply,
|
|
|
|
* if there are some spare threads, the verification of attestation records will happen
|
|
|
|
* concurrently.
|
|
|
|
*
|
|
|
|
* There is a thread-safe `failure` variable which is set whenever an attestation fails
|
|
|
|
* validation. This is so all attestation validation is halted if a single bad attestation
|
|
|
|
* is found.
|
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let failure: RwLock<Option<SszBeaconBlockValidationError>> = RwLock::new(None);
|
2018-10-02 07:35:03 +00:00
|
|
|
let mut deserialized_attestations: Vec<AttestationRecord> = other_attestations
|
|
|
|
.par_iter()
|
|
|
|
.filter_map(|attestation_ssz| {
|
|
|
|
/*
|
|
|
|
* If some thread has set the `failure` variable to `Some(error)` the abandon
|
|
|
|
* attestation serialization and validation.
|
|
|
|
*/
|
|
|
|
if let Some(_) = *failure.read().unwrap() {
|
|
|
|
return None;
|
|
|
|
}
|
|
|
|
/*
|
|
|
|
* If there has not been a failure yet, attempt to serialize and validate the
|
|
|
|
* attestation.
|
|
|
|
*/
|
|
|
|
match AttestationRecord::ssz_decode(&attestation_ssz, 0) {
|
|
|
|
/*
|
|
|
|
* Deserialization failed, therefore the block is invalid.
|
|
|
|
*/
|
|
|
|
Err(e) => {
|
|
|
|
let mut failure = failure.write().unwrap();
|
2018-10-16 02:59:45 +00:00
|
|
|
*failure = Some(SszBeaconBlockValidationError::from(e));
|
2018-10-02 07:35:03 +00:00
|
|
|
None
|
|
|
|
}
|
|
|
|
/*
|
|
|
|
* Deserialization succeeded and the attestation should be validated.
|
|
|
|
*/
|
|
|
|
Ok((attestation, _)) => {
|
|
|
|
match attestation_validation_context.validate_attestation(&attestation) {
|
|
|
|
/*
|
|
|
|
* Attestation validation failed with some error.
|
|
|
|
*/
|
|
|
|
Err(e) => {
|
|
|
|
let mut failure = failure.write().unwrap();
|
2018-10-16 02:59:45 +00:00
|
|
|
*failure = Some(SszBeaconBlockValidationError::from(e));
|
2018-10-02 07:35:03 +00:00
|
|
|
None
|
|
|
|
}
|
|
|
|
/*
|
|
|
|
* Attestation validation succeded.
|
|
|
|
*/
|
|
|
|
Ok(_) => Some(attestation)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
match failure.into_inner() {
|
2018-10-16 02:59:45 +00:00
|
|
|
Err(_) => return Err(SszBeaconBlockValidationError::RwLockPoisoned),
|
2018-10-02 07:35:03 +00:00
|
|
|
Ok(failure) => {
|
|
|
|
match failure {
|
|
|
|
Some(error) => return Err(error),
|
|
|
|
_ => ()
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Add the first attestation to the vec of deserialized attestations at
|
|
|
|
* index 0.
|
|
|
|
*/
|
|
|
|
deserialized_attestations.insert(0, first_attestation);
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
let (ancestor_hashes, _) = Decodable::ssz_decode(&b.ancestor_hashes(), 0)
|
|
|
|
.map_err(|_| SszBeaconBlockValidationError::BadAncestorHashesSsz)?;
|
|
|
|
let (specials, _) = Decodable::ssz_decode(&b.specials(), 0)
|
|
|
|
.map_err(|_| SszBeaconBlockValidationError::BadSpecialsSsz)?;
|
|
|
|
|
2018-10-02 07:35:03 +00:00
|
|
|
/*
|
|
|
|
* If we have reached this point, the block is a new valid block that is worthy of
|
|
|
|
* processing.
|
|
|
|
*/
|
2018-10-16 02:59:45 +00:00
|
|
|
let block = BeaconBlock {
|
|
|
|
slot: block_slot,
|
2018-10-02 07:35:03 +00:00
|
|
|
randao_reveal: Hash256::from(b.randao_reveal()),
|
2018-10-16 02:59:45 +00:00
|
|
|
pow_chain_reference: Hash256::from(pow_chain_reference),
|
|
|
|
ancestor_hashes,
|
2018-10-02 07:35:03 +00:00
|
|
|
active_state_root: Hash256::from(b.act_state_root()),
|
|
|
|
crystallized_state_root: Hash256::from(b.cry_state_root()),
|
2018-10-16 02:59:45 +00:00
|
|
|
attestations: deserialized_attestations,
|
|
|
|
specials,
|
2018-10-02 07:35:03 +00:00
|
|
|
};
|
2018-10-16 02:59:45 +00:00
|
|
|
Ok((BeaconBlockStatus::NewBlock, Some(block)))
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl From<DBError> for SszBeaconBlockValidationError {
|
2018-10-02 07:35:03 +00:00
|
|
|
fn from(e: DBError) -> Self {
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockValidationError::DBError(e.message)
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl From<AttestationSplitError> for SszBeaconBlockValidationError {
|
2018-10-02 07:35:03 +00:00
|
|
|
fn from(e: AttestationSplitError) -> Self {
|
|
|
|
match e {
|
|
|
|
AttestationSplitError::TooShort =>
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockValidationError::BadAttestationSsz
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl From<SszBeaconBlockError> for SszBeaconBlockValidationError {
|
|
|
|
fn from(e: SszBeaconBlockError) -> Self {
|
2018-10-02 07:35:03 +00:00
|
|
|
match e {
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockError::TooShort =>
|
|
|
|
SszBeaconBlockValidationError::DBError("Bad parent block in db.".to_string()),
|
|
|
|
SszBeaconBlockError::TooLong =>
|
|
|
|
SszBeaconBlockValidationError::DBError("Bad parent block in db.".to_string()),
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl From<DecodeError> for SszBeaconBlockValidationError {
|
2018-10-02 07:35:03 +00:00
|
|
|
fn from(e: DecodeError) -> Self {
|
|
|
|
match e {
|
|
|
|
DecodeError::TooShort =>
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockValidationError::BadAttestationSsz,
|
2018-10-02 07:35:03 +00:00
|
|
|
DecodeError::TooLong =>
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockValidationError::BadAttestationSsz,
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-16 02:59:45 +00:00
|
|
|
impl From<AttestationValidationError> for SszBeaconBlockValidationError {
|
2018-10-02 07:35:03 +00:00
|
|
|
fn from(e: AttestationValidationError) -> Self {
|
2018-10-16 02:59:45 +00:00
|
|
|
SszBeaconBlockValidationError::AttestationValidationError(e)
|
2018-10-02 07:35:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* Tests for block validation are contained in the root directory "tests" directory (AKA
|
|
|
|
* "integration tests directory").
|
|
|
|
*/
|