[#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:
Honza Rychnovsky 2022-08-26 15:31:56 +02:00 committed by GitHub
parent 1fae5beab6
commit 12c23dd054
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 227 additions and 129 deletions

View File

@ -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)
}
}

View File

@ -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
)
}

View File

@ -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
)