[#664] Check sapling files size
* [#664] Transfer maximum bytes from remote file * Tests for limiting sapling files max size * Address comments from review * Optimization of sapling files download * Add units to data class Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
parent
1fae5beab6
commit
12c23dd054
|
@ -1,85 +1,137 @@
|
|||
package cash.z.ecc.android.sdk.internal
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.fixture.SaplingParamsFixture
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Ignore(
|
||||
"These tests need to be refactored to a separate test module. They cause SSLHandshakeException: Chain " +
|
||||
"validation failed on CI"
|
||||
)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SaplingParamToolTest {
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
private val spendSaplingParams = SaplingParamsFixture.newFile()
|
||||
|
||||
val cacheDir = "${context.cacheDir.absolutePath}/params"
|
||||
private val outputSaplingParams = SaplingParamsFixture.newFile(
|
||||
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||
SaplingParamsFixture.OUTPUT_FILE_NAME,
|
||||
SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// clear the param files
|
||||
runBlocking { SaplingParamTool.clear(cacheDir) }
|
||||
runBlocking { SaplingParamTool.clear(spendSaplingParams.destinationDirectoryPath) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("This test is broken")
|
||||
fun testFilesExists() = runBlocking {
|
||||
fun test_files_exists() = runBlocking {
|
||||
// Given
|
||||
SaplingParamTool.fetchParams(cacheDir)
|
||||
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||
|
||||
// When
|
||||
val result = SaplingParamTool.validate(cacheDir)
|
||||
val result = SaplingParamTool.validate(SaplingParamsFixture.DESTINATION_DIRECTORY)
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun output_file_exists() = runBlocking {
|
||||
// Given
|
||||
SaplingParamTool.fetchParams(cacheDir)
|
||||
File(cacheDir, ZcashSdk.OUTPUT_PARAM_FILE_NAME).delete()
|
||||
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||
File(spendSaplingParams.destinationDirectoryPath, spendSaplingParams.fileName).delete()
|
||||
|
||||
// When
|
||||
val result = SaplingParamTool.validate(cacheDir)
|
||||
val result = SaplingParamTool.validate(spendSaplingParams.destinationDirectoryPath)
|
||||
|
||||
// Then
|
||||
assertFalse(result, "Validation should fail when the spend params are missing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun param_file_exists() = runBlocking {
|
||||
fun spend_file_exists() = runBlocking {
|
||||
// Given
|
||||
SaplingParamTool.fetchParams(cacheDir)
|
||||
File(cacheDir, ZcashSdk.SPEND_PARAM_FILE_NAME).delete()
|
||||
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||
File(outputSaplingParams.destinationDirectoryPath, outputSaplingParams.fileName).delete()
|
||||
|
||||
// When
|
||||
val result = SaplingParamTool.validate(cacheDir)
|
||||
val result = SaplingParamTool.validate(outputSaplingParams.destinationDirectoryPath)
|
||||
|
||||
// Then
|
||||
assertFalse(result, "Validation should fail when the spend params are missing")
|
||||
assertFalse(result, "Validation should fail when the output params are missing")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsufficientDeviceStorage() = runBlocking {
|
||||
// Given
|
||||
SaplingParamTool.fetchParams(cacheDir)
|
||||
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||
|
||||
assertFalse(false, "insufficient storage")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSufficientDeviceStorageForOnlyOneFile() = runBlocking {
|
||||
SaplingParamTool.fetchParams(cacheDir)
|
||||
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||
|
||||
assertFalse(false, "insufficient storage")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun check_all_files_fetched() = runBlocking {
|
||||
val expectedSpendFile = File(
|
||||
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||
SaplingParamsFixture.SPEND_FILE_NAME
|
||||
)
|
||||
val expectedOutputFile = File(
|
||||
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||
SaplingParamsFixture.OUTPUT_FILE_NAME
|
||||
)
|
||||
|
||||
SaplingParamTool.ensureParams(SaplingParamsFixture.DESTINATION_DIRECTORY)
|
||||
|
||||
val actualFiles = File(SaplingParamsFixture.DESTINATION_DIRECTORY).listFiles()
|
||||
assertNotNull(actualFiles)
|
||||
|
||||
assertContains(actualFiles, expectedSpendFile)
|
||||
|
||||
assertContains(actualFiles, expectedOutputFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun check_correct_spend_param_file_size() = runBlocking {
|
||||
SaplingParamTool.fetchParams(spendSaplingParams)
|
||||
|
||||
val expectedSpendFile = File(
|
||||
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||
SaplingParamsFixture.SPEND_FILE_NAME
|
||||
)
|
||||
|
||||
assertTrue(expectedSpendFile.length() < SaplingParamsFixture.SPEND_FILE_MAX_SIZE)
|
||||
assertFalse(expectedSpendFile.length() < SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun check_correct_output_param_file_size() = runBlocking {
|
||||
SaplingParamTool.fetchParams(outputSaplingParams)
|
||||
|
||||
val expectedOutputFile = File(
|
||||
SaplingParamsFixture.DESTINATION_DIRECTORY,
|
||||
SaplingParamsFixture.OUTPUT_FILE_NAME
|
||||
)
|
||||
|
||||
assertTrue(expectedOutputFile.length() < SaplingParamsFixture.OUTPUT_FILE_MAX_SIZE)
|
||||
assertFalse(expectedOutputFile.length() > SaplingParamsFixture.SPEND_FILE_MAX_SIZE)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package cash.z.ecc.fixture
|
||||
|
||||
import cash.z.ecc.android.sdk.ext.ZcashSdk
|
||||
import cash.z.ecc.android.sdk.internal.SaplingFileParameters
|
||||
import cash.z.ecc.android.sdk.internal.SaplingParamTool
|
||||
import cash.z.ecc.android.sdk.test.getAppContext
|
||||
import java.io.File
|
||||
|
||||
object SaplingParamsFixture {
|
||||
|
||||
val DESTINATION_DIRECTORY: String = File(getAppContext().cacheDir, "params").absolutePath
|
||||
|
||||
const val SPEND_FILE_NAME = ZcashSdk.SPEND_PARAM_FILE_NAME
|
||||
const val SPEND_FILE_MAX_SIZE = SaplingParamTool.SPEND_PARAM_FILE_MAX_BYTES_SIZE
|
||||
|
||||
const val OUTPUT_FILE_NAME = ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
const val OUTPUT_FILE_MAX_SIZE = SaplingParamTool.OUTPUT_PARAM_FILE_MAX_BYTES_SIZE
|
||||
|
||||
internal fun newFile(
|
||||
destinationDirectoryPath: String = DESTINATION_DIRECTORY,
|
||||
fileName: String = SPEND_FILE_NAME,
|
||||
fileMaxSize: Long = SPEND_FILE_MAX_SIZE
|
||||
) = SaplingFileParameters(
|
||||
destinationDirectoryPath = destinationDirectoryPath,
|
||||
fileName = fileName,
|
||||
fileMaxSizeBytes = fileMaxSize
|
||||
)
|
||||
}
|
|
@ -17,128 +17,146 @@ import java.nio.channels.Channels
|
|||
// TODO [#665]: https://github.com/zcash/zcash-android-wallet-sdk/issues/665
|
||||
// TODO [#665]: Recover from corrupted sapling-spend.params and sapling-output.params
|
||||
|
||||
// TODO [#664]: https://github.com/zcash/zcash-android-wallet-sdk/issues/664
|
||||
// TODO [#664]: Check size of download for sapling-spend.params and sapling-output.params
|
||||
|
||||
// TODO [#611]: https://github.com/zcash/zcash-android-wallet-sdk/issues/611
|
||||
// TODO [#611]: Move Params Directory to No Backup Directory
|
||||
|
||||
@Suppress("UtilityClassWithPublicConstructor")
|
||||
class SaplingParamTool {
|
||||
object SaplingParamTool {
|
||||
/**
|
||||
* Maximum file size for the sapling spend params - 50MB
|
||||
*/
|
||||
internal const val SPEND_PARAM_FILE_MAX_BYTES_SIZE = 50L * 1024L * 1024L
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Checks the given directory for the output and spending params and calls [fetchParams] if
|
||||
* they're missing.
|
||||
*
|
||||
* @param destinationDir the directory where the params should be stored.
|
||||
*/
|
||||
suspend fun ensureParams(destinationDir: String) {
|
||||
var hadError = false
|
||||
arrayOf(
|
||||
/**
|
||||
* Maximum file size for the sapling spend params - 5MB
|
||||
*/
|
||||
internal const val OUTPUT_PARAM_FILE_MAX_BYTES_SIZE = 5L * 1024L * 1024L
|
||||
|
||||
/**
|
||||
* Checks the given directory for the output and spending params and calls [fetchParams] for those, which are
|
||||
* missing.
|
||||
*
|
||||
* @param destinationDir the directory where the params should be stored.
|
||||
*
|
||||
* @throws TransactionEncoderException.MissingParamsException in case of failure while checking sapling params
|
||||
* files
|
||||
*/
|
||||
@Throws(TransactionEncoderException.MissingParamsException::class)
|
||||
suspend fun ensureParams(destinationDir: String) {
|
||||
arrayOf(
|
||||
SaplingFileParameters(
|
||||
destinationDir,
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).forEach { paramFileName ->
|
||||
if (!File(destinationDir, paramFileName).existsSuspend()) {
|
||||
twig("WARNING: $paramFileName not found at location: $destinationDir")
|
||||
hadError = true
|
||||
}
|
||||
}
|
||||
if (hadError) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
Bush.trunk.twigTask("attempting to download missing params") {
|
||||
fetchParams(destinationDir)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
twig("failed to fetch params due to: $e")
|
||||
throw TransactionEncoderException.MissingParamsException
|
||||
}
|
||||
SPEND_PARAM_FILE_MAX_BYTES_SIZE
|
||||
),
|
||||
SaplingFileParameters(
|
||||
destinationDir,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME,
|
||||
OUTPUT_PARAM_FILE_MAX_BYTES_SIZE
|
||||
)
|
||||
).filter {
|
||||
!File(it.destinationDirectoryPath, it.fileName).existsSuspend()
|
||||
}.forEach {
|
||||
try {
|
||||
twig("Attempting to download missing params: ${it.fileName}.")
|
||||
fetchParams(it)
|
||||
} catch (e: TransactionEncoderException.FetchParamsException) {
|
||||
twig("Failed to fetch params ${it.fileName} due to: $e")
|
||||
throw TransactionEncoderException.MissingParamsException
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and store the params into the given directory.
|
||||
*
|
||||
* @param destinationDir the directory where the params will be stored. It's assumed that we
|
||||
* have write access to this directory. Typically, this should be the app's cache directory
|
||||
* because it is not harmful if these files are cleared by the user since they are downloaded
|
||||
* on-demand.
|
||||
*/
|
||||
suspend fun fetchParams(destinationDir: String) {
|
||||
var failureMessage = ""
|
||||
arrayOf(
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).forEach { paramFileName ->
|
||||
val url = URL("${ZcashSdk.CLOUD_PARAM_DIR_URL}/$paramFileName")
|
||||
if (!validate(destinationDir)) {
|
||||
twig("Fetching sapling params files failed.")
|
||||
throw TransactionEncoderException.MissingParamsException
|
||||
}
|
||||
}
|
||||
|
||||
val file = File(destinationDir, paramFileName)
|
||||
if (file.parentFile?.existsSuspend() == true) {
|
||||
twig("Directory ${file.parentFile?.name} exists!")
|
||||
} else {
|
||||
twig("Directory did not exist attempting to make it.")
|
||||
file.parentFile?.mkdirsSuspend()
|
||||
}
|
||||
/**
|
||||
* Download and store the params file into the given directory. It also checks the file size to eliminate the
|
||||
* risk of downloading potentially large file from a malicious server.
|
||||
*
|
||||
* @param paramsToFetch parameters wrapper class, which holds information about it
|
||||
*
|
||||
* @throws TransactionEncoderException.FetchParamsException if any error while downloading the params file occurs
|
||||
*/
|
||||
@Throws(TransactionEncoderException.FetchParamsException::class)
|
||||
internal suspend fun fetchParams(paramsToFetch: SaplingFileParameters) {
|
||||
val url = URL("${ZcashSdk.CLOUD_PARAM_DIR_URL}/${paramsToFetch.fileName}")
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Channels.newChannel(url.openStream()).use { readableByteChannel ->
|
||||
file.outputStream().use { fileOutputStream ->
|
||||
fileOutputStream.channel.use { fileChannel ->
|
||||
// transfers bytes from stream to file (position 0 to the end position)
|
||||
fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE)
|
||||
}
|
||||
}
|
||||
val file = File(paramsToFetch.destinationDirectoryPath, paramsToFetch.fileName)
|
||||
if (file.parentFile?.existsSuspend() == true) {
|
||||
twig("Directory ${file.parentFile?.name} exists!")
|
||||
} else {
|
||||
twig("Directory did not exist attempting to make it.")
|
||||
file.parentFile?.mkdirsSuspend()
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Channels.newChannel(url.openStream()).use { readableByteChannel ->
|
||||
file.outputStream().use { fileOutputStream ->
|
||||
fileOutputStream.channel.use { fileChannel ->
|
||||
// Transfers bytes from stream to file from position 0 to end position or to max
|
||||
// file size limit. This eliminates the risk of downloading potentially large files
|
||||
// from a malicious server. We need to make a check of the file hash then.
|
||||
fileChannel.transferFrom(readableByteChannel, 0, paramsToFetch.fileMaxSizeBytes)
|
||||
}
|
||||
}.onFailure { exception ->
|
||||
// IllegalArgumentException - If the preconditions on the parameters do not hold
|
||||
// NonReadableChannelException - If the source channel was not opened for reading
|
||||
// NonWritableChannelException - If this channel was not opened for writing
|
||||
// ClosedChannelException - If either this channel or the source channel is closed
|
||||
// AsynchronousCloseException - If another thread closes either channel while the transfer is
|
||||
// in progress
|
||||
// ClosedByInterruptException - If another thread interrupts the current thread while the
|
||||
// transfer is in progress, thereby closing both channels and setting the current thread's
|
||||
// interrupt status
|
||||
// IOException - If some other I/O error occurs
|
||||
failureMessage += "Error while fetching $paramFileName, caused by $exception\n"
|
||||
twig(failureMessage)
|
||||
}.onSuccess {
|
||||
twig("Fetch and write of $paramFileName succeeded.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failureMessage.isNotEmpty()) {
|
||||
throw TransactionEncoderException.FetchParamsException(failureMessage)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(destinationDir: String) {
|
||||
if (validate(destinationDir)) {
|
||||
arrayOf(
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).forEach { paramFileName ->
|
||||
val file = File(destinationDir, paramFileName)
|
||||
if (file.deleteRecursivelySuspend()) {
|
||||
twig("Files deleted successfully")
|
||||
} else {
|
||||
twig("Error: Files not able to be deleted!")
|
||||
}
|
||||
}.onFailure { exception ->
|
||||
// IllegalArgumentException - If the preconditions on the parameters do not hold
|
||||
// NonReadableChannelException - If the source channel was not opened for reading
|
||||
// NonWritableChannelException - If this channel was not opened for writing
|
||||
// ClosedChannelException - If either this channel or the source channel is closed
|
||||
// AsynchronousCloseException - If another thread closes either channel while the transfer is
|
||||
// in progress
|
||||
// ClosedByInterruptException - If another thread interrupts the current thread while the
|
||||
// transfer is in progress, thereby closing both channels and setting the current thread's
|
||||
// interrupt status
|
||||
// IOException - If some other I/O error occurs
|
||||
"Error while fetching ${paramsToFetch.fileName}, caused by $exception".also {
|
||||
twig(it)
|
||||
throw TransactionEncoderException.FetchParamsException(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validate(destinationDir: String): Boolean {
|
||||
return arrayOf(
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).all { paramFileName ->
|
||||
File(destinationDir, paramFileName).existsSuspend()
|
||||
}.also {
|
||||
println("Param files${if (!it) "did not" else ""} both exist!")
|
||||
}.onSuccess {
|
||||
twig("Fetch and write of ${paramsToFetch.fileName} succeeded.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear(destinationDir: String) {
|
||||
if (validate(destinationDir)) {
|
||||
arrayOf(
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).forEach { paramFileName ->
|
||||
val file = File(destinationDir, paramFileName)
|
||||
if (file.deleteRecursivelySuspend()) {
|
||||
twig("Files deleted successfully")
|
||||
} else {
|
||||
twig("Error: Files not able to be deleted!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validate(destinationDir: String): Boolean {
|
||||
return arrayOf(
|
||||
ZcashSdk.SPEND_PARAM_FILE_NAME,
|
||||
ZcashSdk.OUTPUT_PARAM_FILE_NAME
|
||||
).all { paramFileName ->
|
||||
File(destinationDir, paramFileName).existsSuspend()
|
||||
}.also {
|
||||
println("Param files ${if (!it) "did not" else ""} both exist!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sapling file parameters class to hold each sapling file attributes.
|
||||
*/
|
||||
internal data class SaplingFileParameters(
|
||||
val destinationDirectoryPath: String,
|
||||
val fileName: String,
|
||||
val fileMaxSizeBytes: Long
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue