mirror of
https://github.com/corda/corda.git
synced 2025-04-28 15:02:59 +00:00
Simple Attachment Storage implementation using Requery/H2 database
This commit is contained in:
parent
0600bfa061
commit
1e78d6a3a7
@ -204,7 +204,7 @@ inline fun elapsedTime(block: () -> Unit): Duration {
|
|||||||
val start = System.nanoTime()
|
val start = System.nanoTime()
|
||||||
block()
|
block()
|
||||||
val end = System.nanoTime()
|
val end = System.nanoTime()
|
||||||
return Duration.ofNanos(end-start)
|
return Duration.ofNanos(end - start)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add inline back when a new Kotlin version is released and check if the java.lang.VerifyError
|
// TODO: Add inline back when a new Kotlin version is released and check if the java.lang.VerifyError
|
||||||
@ -280,13 +280,16 @@ class TransientProperty<out T>(private val initializer: () -> T) {
|
|||||||
/**
|
/**
|
||||||
* Given a path to a zip file, extracts it to the given directory.
|
* Given a path to a zip file, extracts it to the given directory.
|
||||||
*/
|
*/
|
||||||
fun extractZipFile(zipFile: Path, toDirectory: Path) {
|
fun extractZipFile(zipFile: Path, toDirectory: Path) = extractZipFile(Files.newInputStream(zipFile), toDirectory)
|
||||||
val normalisedDirectory = toDirectory.normalize().createDirectories()
|
|
||||||
|
|
||||||
zipFile.read {
|
/**
|
||||||
val zip = ZipInputStream(BufferedInputStream(it))
|
* Given a zip file input stream, extracts it to the given directory.
|
||||||
|
*/
|
||||||
|
fun extractZipFile(inputStream: InputStream, toDirectory: Path) {
|
||||||
|
val normalisedDirectory = toDirectory.normalize().createDirectories()
|
||||||
|
ZipInputStream(BufferedInputStream(inputStream)).use {
|
||||||
while (true) {
|
while (true) {
|
||||||
val e = zip.nextEntry ?: break
|
val e = it.nextEntry ?: break
|
||||||
val outPath = (normalisedDirectory / e.name).normalize()
|
val outPath = (normalisedDirectory / e.name).normalize()
|
||||||
|
|
||||||
// Security checks: we should reject a zip that contains tricksy paths that try to escape toDirectory.
|
// Security checks: we should reject a zip that contains tricksy paths that try to escape toDirectory.
|
||||||
@ -297,9 +300,9 @@ fun extractZipFile(zipFile: Path, toDirectory: Path) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
outPath.write { out ->
|
outPath.write { out ->
|
||||||
ByteStreams.copy(zip, out)
|
ByteStreams.copy(it, out)
|
||||||
}
|
}
|
||||||
zip.closeEntry()
|
it.closeEntry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -394,13 +397,16 @@ private class ObservableToFuture<T>(observable: Observable<T>) : AbstractFuture<
|
|||||||
override fun onNext(value: T) {
|
override fun onNext(value: T) {
|
||||||
set(value)
|
set(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
setException(e)
|
setException(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
|
override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
return super.cancel(mayInterruptIfRunning)
|
return super.cancel(mayInterruptIfRunning)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompleted() {}
|
override fun onCompleted() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,11 +3,20 @@ package net.corda.core.node.services
|
|||||||
import net.corda.core.contracts.Attachment
|
import net.corda.core.contracts.Attachment
|
||||||
import net.corda.core.crypto.SecureHash
|
import net.corda.core.crypto.SecureHash
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An attachment store records potentially large binary objects, identified by their hash.
|
* An attachment store records potentially large binary objects, identified by their hash.
|
||||||
*/
|
*/
|
||||||
interface AttachmentStorage {
|
interface AttachmentStorage {
|
||||||
|
/**
|
||||||
|
* If true, newly inserted attachments will be unzipped to a subdirectory of the [storePath]. This is intended for
|
||||||
|
* human browsing convenience: the attachment itself will still be the file (that is, edits to the extracted directory
|
||||||
|
* will not have any effect).
|
||||||
|
*/
|
||||||
|
var automaticallyExtractAttachments : Boolean
|
||||||
|
var storePath : Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a handle to a locally stored attachment, or null if it's not known. The handle can be used to open
|
* Returns a handle to a locally stored attachment, or null if it's not known. The handle can be used to open
|
||||||
* a stream for the data, which will be a zip/jar file.
|
* a stream for the data, which will be a zip/jar file.
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
package net.corda.core.schemas.requery.converters
|
||||||
|
|
||||||
|
import io.requery.Converter
|
||||||
|
import java.sql.Blob
|
||||||
|
import javax.sql.rowset.serial.SerialBlob
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts from a [ByteArray] to a [Blob].
|
||||||
|
*/
|
||||||
|
class BlobConverter : Converter<ByteArray, Blob> {
|
||||||
|
|
||||||
|
override fun getMappedType(): Class<ByteArray> = ByteArray::class.java
|
||||||
|
|
||||||
|
override fun getPersistedType(): Class<Blob> = Blob::class.java
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates BLOB(INT.MAX) = 2 GB
|
||||||
|
*/
|
||||||
|
override fun getPersistedSize(): Int? = null
|
||||||
|
|
||||||
|
override fun convertToPersisted(value: ByteArray?): Blob? {
|
||||||
|
return value?.let { SerialBlob(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToMapped(type: Class<out ByteArray>?, value: Blob?): ByteArray? {
|
||||||
|
return value?.getBytes(1, value.length().toInt())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package net.corda.core.schemas.requery.converters
|
||||||
|
|
||||||
|
import io.requery.Converter
|
||||||
|
import net.corda.core.crypto.SecureHash
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert from a [SecureHash] to a [String]
|
||||||
|
*/
|
||||||
|
class SecureHashConverter : Converter<SecureHash, String> {
|
||||||
|
|
||||||
|
override fun getMappedType(): Class<SecureHash> = SecureHash::class.java
|
||||||
|
|
||||||
|
override fun getPersistedType(): Class<String> = String::class.java
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SecureHash consists of 32 bytes which need VARCHAR(64) in hex
|
||||||
|
* TODO: think about other hash widths
|
||||||
|
*/
|
||||||
|
override fun getPersistedSize(): Int? = 64
|
||||||
|
|
||||||
|
override fun convertToPersisted(value: SecureHash?): String? {
|
||||||
|
return value?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToMapped(type: Class<out SecureHash>, value: String?): SecureHash? {
|
||||||
|
return value?.let { SecureHash.parse(value) }
|
||||||
|
}
|
||||||
|
}
|
@ -134,13 +134,20 @@ class ResolveTransactionsFlowTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun attachment() {
|
fun attachment() {
|
||||||
val id = a.services.storageService.attachments.importAttachment("Some test file".toByteArray().opaque().open())
|
// TODO: this operation should not require an explicit transaction
|
||||||
|
val id = databaseTransaction(a.database) {
|
||||||
|
a.services.storageService.attachments.importAttachment("Some test file".toByteArray().opaque().open())
|
||||||
|
}
|
||||||
val stx2 = makeTransactions(withAttachment = id).second
|
val stx2 = makeTransactions(withAttachment = id).second
|
||||||
val p = ResolveTransactionsFlow(stx2, a.info.legalIdentity)
|
val p = ResolveTransactionsFlow(stx2, a.info.legalIdentity)
|
||||||
val future = b.services.startFlow(p).resultFuture
|
val future = b.services.startFlow(p).resultFuture
|
||||||
net.runNetwork()
|
net.runNetwork()
|
||||||
future.getOrThrow()
|
future.getOrThrow()
|
||||||
assertNotNull(b.services.storageService.attachments.openAttachment(id))
|
|
||||||
|
// TODO: this operation should not require an explicit transaction
|
||||||
|
databaseTransaction(b.database) {
|
||||||
|
assertNotNull(b.services.storageService.attachments.openAttachment(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOCSTART 2
|
// DOCSTART 2
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
package net.corda.node.services.persistence.schemas
|
||||||
|
|
||||||
|
import io.requery.*
|
||||||
|
import net.corda.core.crypto.SecureHash
|
||||||
|
import net.corda.core.schemas.requery.converters.BlobConverter
|
||||||
|
|
||||||
|
@Table(name = "attachments")
|
||||||
|
@Entity(model = "persistence")
|
||||||
|
interface Attachment : Persistable {
|
||||||
|
|
||||||
|
@get:Key
|
||||||
|
@get:Column(name = "att_id", index = true)
|
||||||
|
var attId: SecureHash
|
||||||
|
|
||||||
|
@get:Column(name = "content")
|
||||||
|
@get:Convert(BlobConverter::class)
|
||||||
|
var content: ByteArray
|
||||||
|
}
|
@ -151,6 +151,9 @@ dependencies {
|
|||||||
compile 'commons-codec:commons-codec:1.10'
|
compile 'commons-codec:commons-codec:1.10'
|
||||||
compile 'com.github.bft-smart:library:master-v1.1-beta-g6215ec8-87'
|
compile 'com.github.bft-smart:library:master-v1.1-beta-g6215ec8-87'
|
||||||
|
|
||||||
|
// Requery: object mapper for Kotlin
|
||||||
|
compile "io.requery:requery-kotlin:$requery_version"
|
||||||
|
|
||||||
// Integration test helpers
|
// Integration test helpers
|
||||||
integrationTestCompile "junit:junit:$junit_version"
|
integrationTestCompile "junit:junit:$junit_version"
|
||||||
}
|
}
|
||||||
|
@ -502,7 +502,7 @@ abstract class AbstractNode(open val configuration: NodeConfiguration,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun constructStorageService(attachments: NodeAttachmentService,
|
protected open fun constructStorageService(attachments: AttachmentStorage,
|
||||||
transactionStorage: TransactionStorage,
|
transactionStorage: TransactionStorage,
|
||||||
stateMachineRecordedTransactionMappingStorage: StateMachineRecordedTransactionMappingStorage) =
|
stateMachineRecordedTransactionMappingStorage: StateMachineRecordedTransactionMappingStorage) =
|
||||||
StorageServiceImpl(attachments, transactionStorage, stateMachineRecordedTransactionMappingStorage)
|
StorageServiceImpl(attachments, transactionStorage, stateMachineRecordedTransactionMappingStorage)
|
||||||
@ -547,13 +547,13 @@ abstract class AbstractNode(open val configuration: NodeConfiguration,
|
|||||||
|
|
||||||
protected open fun generateKeyPair() = cryptoGenerateKeyPair()
|
protected open fun generateKeyPair() = cryptoGenerateKeyPair()
|
||||||
|
|
||||||
protected fun makeAttachmentStorage(dir: Path): NodeAttachmentService {
|
protected fun makeAttachmentStorage(dir: Path): AttachmentStorage {
|
||||||
val attachmentsDir = dir / "attachments"
|
val attachmentsDir = dir / "attachments"
|
||||||
try {
|
try {
|
||||||
attachmentsDir.createDirectory()
|
attachmentsDir.createDirectory()
|
||||||
} catch (e: FileAlreadyExistsException) {
|
} catch (e: FileAlreadyExistsException) {
|
||||||
}
|
}
|
||||||
return NodeAttachmentService(attachmentsDir, services.monitoringService.metrics)
|
return NodeAttachmentService(attachmentsDir, configuration.dataSourceProperties, services.monitoringService.metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun createNodeDir() {
|
protected fun createNodeDir() {
|
||||||
|
@ -109,9 +109,26 @@ class CordaRPCOpsImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachmentExists(id: SecureHash) = services.storageService.attachments.openAttachment(id) != null
|
override fun attachmentExists(id: SecureHash): Boolean {
|
||||||
override fun openAttachment(id: SecureHash) = services.storageService.attachments.openAttachment(id)!!.open()
|
// TODO: this operation should not require an explicit transaction
|
||||||
override fun uploadAttachment(jar: InputStream) = services.storageService.attachments.importAttachment(jar)
|
return databaseTransaction(database){
|
||||||
|
services.storageService.attachments.openAttachment(id) != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openAttachment(id: SecureHash): InputStream {
|
||||||
|
// TODO: this operation should not require an explicit transaction
|
||||||
|
return databaseTransaction(database) {
|
||||||
|
services.storageService.attachments.openAttachment(id)!!.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun uploadAttachment(jar: InputStream): SecureHash {
|
||||||
|
// TODO: this operation should not require an explicit transaction
|
||||||
|
return databaseTransaction(database){
|
||||||
|
services.storageService.attachments.importAttachment(jar)
|
||||||
|
}
|
||||||
|
}
|
||||||
override fun authoriseContractUpgrade(state: StateAndRef<*>, upgradedContractClass: Class<out UpgradedContract<*, *>>) = services.vaultService.authoriseContractUpgrade(state, upgradedContractClass)
|
override fun authoriseContractUpgrade(state: StateAndRef<*>, upgradedContractClass: Class<out UpgradedContract<*, *>>) = services.vaultService.authoriseContractUpgrade(state, upgradedContractClass)
|
||||||
override fun deauthoriseContractUpgrade(state: StateAndRef<*>) = services.vaultService.deauthoriseContractUpgrade(state)
|
override fun deauthoriseContractUpgrade(state: StateAndRef<*>) = services.vaultService.deauthoriseContractUpgrade(state)
|
||||||
override fun currentNodeTime(): Instant = Instant.now(services.clock)
|
override fun currentNodeTime(): Instant = Instant.now(services.clock)
|
||||||
|
@ -9,9 +9,7 @@ import io.requery.sql.*
|
|||||||
import io.requery.sql.platform.H2
|
import io.requery.sql.platform.H2
|
||||||
import io.requery.util.function.Function
|
import io.requery.util.function.Function
|
||||||
import io.requery.util.function.Supplier
|
import io.requery.util.function.Supplier
|
||||||
import net.corda.core.schemas.requery.converters.InstantConverter
|
import net.corda.core.schemas.requery.converters.*
|
||||||
import net.corda.core.schemas.requery.converters.StateRefConverter
|
|
||||||
import net.corda.core.schemas.requery.converters.VaultStateStatusConverter
|
|
||||||
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
||||||
import java.sql.Connection
|
import java.sql.Connection
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -69,6 +67,8 @@ class KotlinConfigurationTransactionWrapper(private val model: EntityModel,
|
|||||||
val vaultStateStatusConverter = VaultStateStatusConverter()
|
val vaultStateStatusConverter = VaultStateStatusConverter()
|
||||||
customMapping.addConverter(vaultStateStatusConverter, vaultStateStatusConverter.mappedType)
|
customMapping.addConverter(vaultStateStatusConverter, vaultStateStatusConverter.mappedType)
|
||||||
customMapping.addConverter(StateRefConverter(), StateRefConverter::getMappedType.javaClass)
|
customMapping.addConverter(StateRefConverter(), StateRefConverter::getMappedType.javaClass)
|
||||||
|
customMapping.addConverter(SecureHashConverter(), SecureHashConverter::getMappedType.javaClass)
|
||||||
|
|
||||||
return customMapping
|
return customMapping
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,13 +5,20 @@ import com.google.common.annotations.VisibleForTesting
|
|||||||
import com.google.common.hash.Hashing
|
import com.google.common.hash.Hashing
|
||||||
import com.google.common.hash.HashingInputStream
|
import com.google.common.hash.HashingInputStream
|
||||||
import com.google.common.io.CountingInputStream
|
import com.google.common.io.CountingInputStream
|
||||||
import net.corda.core.*
|
|
||||||
import net.corda.core.contracts.Attachment
|
import net.corda.core.contracts.Attachment
|
||||||
|
import net.corda.core.createDirectory
|
||||||
import net.corda.core.crypto.SecureHash
|
import net.corda.core.crypto.SecureHash
|
||||||
|
import net.corda.core.div
|
||||||
|
import net.corda.core.extractZipFile
|
||||||
|
import net.corda.core.isDirectory
|
||||||
import net.corda.core.node.services.AttachmentStorage
|
import net.corda.core.node.services.AttachmentStorage
|
||||||
import net.corda.core.serialization.CordaSerializable
|
import net.corda.core.serialization.CordaSerializable
|
||||||
import net.corda.core.utilities.loggerFor
|
import net.corda.core.utilities.loggerFor
|
||||||
import net.corda.node.services.api.AcceptsFileUpload
|
import net.corda.node.services.api.AcceptsFileUpload
|
||||||
|
import net.corda.node.services.database.RequeryConfiguration
|
||||||
|
import net.corda.node.services.persistence.schemas.AttachmentEntity
|
||||||
|
import net.corda.node.services.persistence.schemas.Models
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.FilterInputStream
|
import java.io.FilterInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.nio.file.*
|
import java.nio.file.*
|
||||||
@ -20,74 +27,69 @@ import java.util.jar.JarInputStream
|
|||||||
import javax.annotation.concurrent.ThreadSafe
|
import javax.annotation.concurrent.ThreadSafe
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores attachments in the specified local directory, which must exist. Doesn't allow new attachments to be uploaded.
|
* Stores attachments in H2 database.
|
||||||
*/
|
*/
|
||||||
@ThreadSafe
|
@ThreadSafe
|
||||||
class NodeAttachmentService(val storePath: Path, metrics: MetricRegistry) : AttachmentStorage, AcceptsFileUpload {
|
class NodeAttachmentService(override var storePath: Path, dataSourceProperties: Properties, metrics: MetricRegistry) : AttachmentStorage, AcceptsFileUpload {
|
||||||
private val log = loggerFor<NodeAttachmentService>()
|
private val log = loggerFor<NodeAttachmentService>()
|
||||||
|
|
||||||
|
val configuration = RequeryConfiguration(dataSourceProperties)
|
||||||
|
val session = configuration.sessionForModel(Models.PERSISTENCE)
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
var checkAttachmentsOnLoad = true
|
var checkAttachmentsOnLoad = true
|
||||||
|
|
||||||
private val attachmentCount = metrics.counter("Attachments")
|
private val attachmentCount = metrics.counter("Attachments")
|
||||||
|
@Volatile override var automaticallyExtractAttachments = false
|
||||||
init {
|
|
||||||
attachmentCount.inc(countAttachments())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just count all non-directories in the attachment store, and assume the admin hasn't dumped any junk there.
|
|
||||||
private fun countAttachments() = storePath.list { it.filter { it.isRegularFile() }.count() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If true, newly inserted attachments will be unzipped to a subdirectory of the [storePath]. This is intended for
|
|
||||||
* human browsing convenience: the attachment itself will still be the file (that is, edits to the extracted directory
|
|
||||||
* will not have any effect).
|
|
||||||
*/
|
|
||||||
@Volatile var automaticallyExtractAttachments = false
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(storePath.isDirectory()) { "$storePath must be a directory" }
|
require(storePath.isDirectory()) { "$storePath must be a directory" }
|
||||||
|
|
||||||
|
session.withTransaction {
|
||||||
|
attachmentCount.inc(session.count(AttachmentEntity::class).get().value().toLong())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CordaSerializable
|
@CordaSerializable
|
||||||
class OnDiskHashMismatch(val file: Path, val actual: SecureHash) : Exception() {
|
class HashMismatchException(val expected: SecureHash, val actual: SecureHash) : Exception() {
|
||||||
override fun toString() = "File $file hashed to $actual: corruption in attachment store?"
|
override fun toString() = "File $expected hashed to $actual: corruption in attachment store?"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a stream and hashes data as it is read: if the entire stream is consumed, then at the end the hash of
|
* Wraps a stream and hashes data as it is read: if the entire stream is consumed, then at the end the hash of
|
||||||
* the read data is compared to the [expected] hash and [OnDiskHashMismatch] is thrown by [close] if they didn't
|
* the read data is compared to the [expected] hash and [HashMismatchException] is thrown by [close] if they didn't
|
||||||
* match. The goal of this is to detect cases where attachments in the store have been tampered with or corrupted
|
* match. The goal of this is to detect cases where attachments in the store have been tampered with or corrupted
|
||||||
* and no longer match their file name. It won't always work: if we read a zip for our own uses and skip around
|
* and no longer match their file name. It won't always work: if we read a zip for our own uses and skip around
|
||||||
* inside it, we haven't read the whole file, so we can't check the hash. But when copying it over the network
|
* inside it, we haven't read the whole file, so we can't check the hash. But when copying it over the network
|
||||||
* this will provide an additional safety check against user error.
|
* this will provide an additional safety check against user error.
|
||||||
*/
|
*/
|
||||||
private class HashCheckingStream(val expected: SecureHash.SHA256,
|
private class HashCheckingStream(val expected: SecureHash.SHA256,
|
||||||
val filePath: Path,
|
val expectedSize: Int,
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
private val counter: CountingInputStream = CountingInputStream(input),
|
private val counter: CountingInputStream = CountingInputStream(input),
|
||||||
private val stream: HashingInputStream = HashingInputStream(Hashing.sha256(), counter)) : FilterInputStream(stream) {
|
private val stream: HashingInputStream = HashingInputStream(Hashing.sha256(), counter)) : FilterInputStream(stream) {
|
||||||
|
|
||||||
private val expectedSize = filePath.size
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
super.close()
|
super.close()
|
||||||
if (counter.count != expectedSize) return
|
|
||||||
|
if (counter.count != expectedSize.toLong()) return
|
||||||
|
|
||||||
val actual = SecureHash.SHA256(stream.hash().asBytes())
|
val actual = SecureHash.SHA256(stream.hash().asBytes())
|
||||||
if (actual != expected)
|
if (actual != expected)
|
||||||
throw OnDiskHashMismatch(filePath, actual)
|
throw HashMismatchException(expected, actual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deliberately not an inner class to avoid holding a reference to the attachments service.
|
|
||||||
private class AttachmentImpl(override val id: SecureHash,
|
private class AttachmentImpl(override val id: SecureHash,
|
||||||
private val path: Path,
|
private val attachment: ByteArray,
|
||||||
private val checkOnLoad: Boolean) : Attachment {
|
private val checkOnLoad: Boolean) : Attachment {
|
||||||
override fun open(): InputStream {
|
override fun open(): InputStream {
|
||||||
var stream = Files.newInputStream(path)
|
|
||||||
|
var stream = ByteArrayInputStream(attachment)
|
||||||
|
|
||||||
// This is just an optional safety check. If it slows things down too much it can be disabled.
|
// This is just an optional safety check. If it slows things down too much it can be disabled.
|
||||||
if (id is SecureHash.SHA256 && checkOnLoad)
|
if (id is SecureHash.SHA256 && checkOnLoad)
|
||||||
stream = HashCheckingStream(id, path, stream)
|
return HashCheckingStream(id, attachment.size, stream)
|
||||||
|
|
||||||
return stream
|
return stream
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,38 +98,54 @@ class NodeAttachmentService(val storePath: Path, metrics: MetricRegistry) : Atta
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun openAttachment(id: SecureHash): Attachment? {
|
override fun openAttachment(id: SecureHash): Attachment? {
|
||||||
val path = storePath / id.toString()
|
val attachment = session.withTransaction {
|
||||||
if (!path.exists()) return null
|
try {
|
||||||
return AttachmentImpl(id, path, checkAttachmentsOnLoad)
|
session.select(AttachmentEntity::class)
|
||||||
|
.where(AttachmentEntity.ATT_ID.eq(id))
|
||||||
|
.get()
|
||||||
|
.single()
|
||||||
|
} catch (e: NoSuchElementException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return AttachmentImpl(id, attachment.content, checkAttachmentsOnLoad)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: PLT-147: The attachment should be randomised to prevent brute force guessing and thus privacy leaks.
|
// TODO: PLT-147: The attachment should be randomised to prevent brute force guessing and thus privacy leaks.
|
||||||
override fun importAttachment(jar: InputStream): SecureHash {
|
override fun importAttachment(jar: InputStream): SecureHash {
|
||||||
require(jar !is JarInputStream)
|
require(jar !is JarInputStream)
|
||||||
val hs = HashingInputStream(Hashing.sha256(), jar)
|
val hs = HashingInputStream(Hashing.sha256(), jar)
|
||||||
val tmp = storePath / "tmp.${UUID.randomUUID()}"
|
val bytes = hs.readBytes()
|
||||||
hs.copyTo(tmp)
|
checkIsAValidJAR(hs)
|
||||||
checkIsAValidJAR(tmp)
|
|
||||||
val id = SecureHash.SHA256(hs.hash().asBytes())
|
val id = SecureHash.SHA256(hs.hash().asBytes())
|
||||||
val finalPath = storePath / id.toString()
|
|
||||||
try {
|
val count = session.withTransaction {
|
||||||
// Move into place atomically or fail if that isn't possible. We don't want a half moved attachment to
|
session.count(AttachmentEntity::class)
|
||||||
// be exposed to parallel threads. This gives us thread safety.
|
.where(AttachmentEntity.ATT_ID.eq(id))
|
||||||
if (!finalPath.exists()) {
|
.get().value()
|
||||||
log.info("Stored new attachment $id")
|
|
||||||
attachmentCount.inc()
|
|
||||||
} else {
|
|
||||||
log.info("Replacing attachment $id - only bother doing this if you're trying to repair file corruption")
|
|
||||||
}
|
|
||||||
tmp.moveTo(finalPath, StandardCopyOption.ATOMIC_MOVE)
|
|
||||||
} finally {
|
|
||||||
tmp.deleteIfExists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
throw FileAlreadyExistsException(id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
session.withTransaction {
|
||||||
|
val attachment = AttachmentEntity()
|
||||||
|
attachment.attId = id
|
||||||
|
attachment.content = bytes
|
||||||
|
session.insert(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentCount.inc()
|
||||||
|
|
||||||
|
log.info("Stored new attachment $id")
|
||||||
|
|
||||||
if (automaticallyExtractAttachments) {
|
if (automaticallyExtractAttachments) {
|
||||||
val extractTo = storePath / "$id.jar"
|
val extractTo = storePath / "$id.jar"
|
||||||
try {
|
try {
|
||||||
extractTo.createDirectory()
|
extractTo.createDirectory()
|
||||||
extractZipFile(finalPath, extractTo)
|
extractZipFile(hs, extractTo)
|
||||||
} catch(e: FileAlreadyExistsException) {
|
} catch(e: FileAlreadyExistsException) {
|
||||||
log.trace("Did not extract attachment jar to directory because it already exists")
|
log.trace("Did not extract attachment jar to directory because it already exists")
|
||||||
} catch(e: Exception) {
|
} catch(e: Exception) {
|
||||||
@ -135,20 +153,19 @@ class NodeAttachmentService(val storePath: Path, metrics: MetricRegistry) : Atta
|
|||||||
// TODO: Delete the extractTo directory here.
|
// TODO: Delete the extractTo directory here.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkIsAValidJAR(path: Path) {
|
private fun checkIsAValidJAR(stream: InputStream) {
|
||||||
// Just iterate over the entries with verification enabled: should be good enough to catch mistakes.
|
// Just iterate over the entries with verification enabled: should be good enough to catch mistakes.
|
||||||
path.read {
|
val jar = JarInputStream(stream)
|
||||||
val jar = JarInputStream(it)
|
while (true) {
|
||||||
while (true) {
|
val cursor = jar.nextJarEntry ?: break
|
||||||
val cursor = jar.nextJarEntry ?: break
|
val entryPath = Paths.get(cursor.name)
|
||||||
val entryPath = Paths.get(cursor.name)
|
// Security check to stop zips trying to escape their rightful place.
|
||||||
// Security check to stop zips trying to escape their rightful place.
|
if (entryPath.isAbsolute || entryPath.normalize() != entryPath || '\\' in cursor.name)
|
||||||
if (entryPath.isAbsolute || entryPath.normalize() != entryPath || '\\' in cursor.name)
|
throw IllegalArgumentException("Path is either absolute or non-normalised: $entryPath")
|
||||||
throw IllegalArgumentException("Path is either absolute or non-normalised: $entryPath")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,22 +6,25 @@ import net.corda.core.crypto.sha256
|
|||||||
import net.corda.core.getOrThrow
|
import net.corda.core.getOrThrow
|
||||||
import net.corda.core.messaging.SingleMessageRecipient
|
import net.corda.core.messaging.SingleMessageRecipient
|
||||||
import net.corda.core.node.services.ServiceInfo
|
import net.corda.core.node.services.ServiceInfo
|
||||||
import net.corda.core.write
|
|
||||||
import net.corda.flows.FetchAttachmentsFlow
|
import net.corda.flows.FetchAttachmentsFlow
|
||||||
import net.corda.flows.FetchDataFlow
|
import net.corda.flows.FetchDataFlow
|
||||||
import net.corda.node.services.config.NodeConfiguration
|
import net.corda.node.services.config.NodeConfiguration
|
||||||
|
import net.corda.node.services.database.RequeryConfiguration
|
||||||
import net.corda.node.services.network.NetworkMapService
|
import net.corda.node.services.network.NetworkMapService
|
||||||
import net.corda.node.services.persistence.NodeAttachmentService
|
import net.corda.node.services.persistence.NodeAttachmentService
|
||||||
|
import net.corda.node.services.persistence.schemas.AttachmentEntity
|
||||||
import net.corda.node.services.transactions.SimpleNotaryService
|
import net.corda.node.services.transactions.SimpleNotaryService
|
||||||
import net.corda.testing.node.MockNetwork
|
import net.corda.testing.node.MockNetwork
|
||||||
import net.i2p.crypto.eddsa.KeyPairGenerator
|
import net.corda.node.utilities.databaseTransaction
|
||||||
|
import net.corda.testing.node.makeTestDataSourceProperties
|
||||||
|
import org.jetbrains.exposed.sql.Database
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.Closeable
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.security.KeyPair
|
import java.security.KeyPair
|
||||||
import java.security.KeyPairGeneratorSpi
|
|
||||||
import java.util.jar.JarOutputStream
|
import java.util.jar.JarOutputStream
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@ -29,10 +32,17 @@ import kotlin.test.assertFailsWith
|
|||||||
|
|
||||||
class AttachmentTests {
|
class AttachmentTests {
|
||||||
lateinit var network: MockNetwork
|
lateinit var network: MockNetwork
|
||||||
|
lateinit var dataSource: Closeable
|
||||||
|
lateinit var database: Database
|
||||||
|
lateinit var configuration: RequeryConfiguration
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
network = MockNetwork()
|
network = MockNetwork()
|
||||||
|
|
||||||
|
val dataSourceProperties = makeTestDataSourceProperties()
|
||||||
|
|
||||||
|
configuration = RequeryConfiguration(dataSourceProperties)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fakeAttachment(): ByteArray {
|
fun fakeAttachment(): ByteArray {
|
||||||
@ -50,7 +60,9 @@ class AttachmentTests {
|
|||||||
val (n0, n1) = network.createTwoNodes()
|
val (n0, n1) = network.createTwoNodes()
|
||||||
|
|
||||||
// Insert an attachment into node zero's store directly.
|
// Insert an attachment into node zero's store directly.
|
||||||
val id = n0.storage.attachments.importAttachment(ByteArrayInputStream(fakeAttachment()))
|
val id = databaseTransaction(n0.database) {
|
||||||
|
n0.storage.attachments.importAttachment(ByteArrayInputStream(fakeAttachment()))
|
||||||
|
}
|
||||||
|
|
||||||
// Get node one to run a flow to fetch it and insert it.
|
// Get node one to run a flow to fetch it and insert it.
|
||||||
network.runNetwork()
|
network.runNetwork()
|
||||||
@ -59,7 +71,10 @@ class AttachmentTests {
|
|||||||
assertEquals(0, f1.resultFuture.getOrThrow().fromDisk.size)
|
assertEquals(0, f1.resultFuture.getOrThrow().fromDisk.size)
|
||||||
|
|
||||||
// Verify it was inserted into node one's store.
|
// Verify it was inserted into node one's store.
|
||||||
val attachment = n1.storage.attachments.openAttachment(id)!!
|
val attachment = databaseTransaction(n1.database) {
|
||||||
|
n1.storage.attachments.openAttachment(id)!!
|
||||||
|
}
|
||||||
|
|
||||||
assertEquals(id, attachment.open().readBytes().sha256())
|
assertEquals(id, attachment.open().readBytes().sha256())
|
||||||
|
|
||||||
// Shut down node zero and ensure node one can still resolve the attachment.
|
// Shut down node zero and ensure node one can still resolve the attachment.
|
||||||
@ -101,11 +116,23 @@ class AttachmentTests {
|
|||||||
}, true, null, null, ServiceInfo(NetworkMapService.type), ServiceInfo(SimpleNotaryService.type))
|
}, true, null, null, ServiceInfo(NetworkMapService.type), ServiceInfo(SimpleNotaryService.type))
|
||||||
val n1 = network.createNode(n0.info.address)
|
val n1 = network.createNode(n0.info.address)
|
||||||
|
|
||||||
|
val attachment = fakeAttachment()
|
||||||
// Insert an attachment into node zero's store directly.
|
// Insert an attachment into node zero's store directly.
|
||||||
val id = n0.storage.attachments.importAttachment(ByteArrayInputStream(fakeAttachment()))
|
val id = databaseTransaction(n0.database) {
|
||||||
|
n0.storage.attachments.importAttachment(ByteArrayInputStream(attachment))
|
||||||
|
}
|
||||||
|
|
||||||
// Corrupt its store.
|
// Corrupt its store.
|
||||||
network.filesystem.getPath("/nodes/0/attachments/$id").write { it.write(byteArrayOf(99, 99, 99, 99)) }
|
val corruptBytes = "arggghhhh".toByteArray()
|
||||||
|
System.arraycopy(corruptBytes, 0, attachment, 0, corruptBytes.size)
|
||||||
|
|
||||||
|
val corruptAttachment = AttachmentEntity()
|
||||||
|
corruptAttachment.attId = id
|
||||||
|
corruptAttachment.content = attachment
|
||||||
|
databaseTransaction(n0.database) {
|
||||||
|
(n0.storage.attachments as NodeAttachmentService).session.update(corruptAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Get n1 to fetch the attachment. Should receive corrupted bytes.
|
// Get n1 to fetch the attachment. Should receive corrupted bytes.
|
||||||
network.runNetwork()
|
network.runNetwork()
|
||||||
|
@ -24,7 +24,6 @@ import net.corda.flows.TwoPartyTradeFlow.Seller
|
|||||||
import net.corda.node.internal.AbstractNode
|
import net.corda.node.internal.AbstractNode
|
||||||
import net.corda.node.services.config.NodeConfiguration
|
import net.corda.node.services.config.NodeConfiguration
|
||||||
import net.corda.node.services.persistence.DBTransactionStorage
|
import net.corda.node.services.persistence.DBTransactionStorage
|
||||||
import net.corda.node.services.persistence.NodeAttachmentService
|
|
||||||
import net.corda.node.services.persistence.StorageServiceImpl
|
import net.corda.node.services.persistence.StorageServiceImpl
|
||||||
import net.corda.node.services.persistence.checkpoints
|
import net.corda.node.services.persistence.checkpoints
|
||||||
import net.corda.node.utilities.databaseTransaction
|
import net.corda.node.utilities.databaseTransaction
|
||||||
@ -90,8 +89,11 @@ class TwoPartyTradeFlowTests {
|
|||||||
databaseTransaction(bobNode.database) {
|
databaseTransaction(bobNode.database) {
|
||||||
bobNode.services.fillWithSomeTestCash(2000.DOLLARS, outputNotary = notaryNode.info.notaryIdentity)
|
bobNode.services.fillWithSomeTestCash(2000.DOLLARS, outputNotary = notaryNode.info.notaryIdentity)
|
||||||
}
|
}
|
||||||
val alicesFakePaper = fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
|
||||||
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, null, notaryNode.info.notaryIdentity).second
|
val alicesFakePaper = databaseTransaction(aliceNode.database) {
|
||||||
|
fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
||||||
|
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, null, notaryNode.info.notaryIdentity).second
|
||||||
|
}
|
||||||
|
|
||||||
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey, notaryKey)
|
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey, notaryKey)
|
||||||
|
|
||||||
@ -135,8 +137,10 @@ class TwoPartyTradeFlowTests {
|
|||||||
databaseTransaction(bobNode.database) {
|
databaseTransaction(bobNode.database) {
|
||||||
bobNode.services.fillWithSomeTestCash(2000.DOLLARS, outputNotary = notaryNode.info.notaryIdentity)
|
bobNode.services.fillWithSomeTestCash(2000.DOLLARS, outputNotary = notaryNode.info.notaryIdentity)
|
||||||
}
|
}
|
||||||
val alicesFakePaper = fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
val alicesFakePaper = databaseTransaction(aliceNode.database) {
|
||||||
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, null, notaryNode.info.notaryIdentity).second
|
fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
||||||
|
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, null, notaryNode.info.notaryIdentity).second
|
||||||
|
}
|
||||||
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey, notaryKey)
|
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey, notaryKey)
|
||||||
val aliceFuture = runBuyerAndSeller(notaryNode, aliceNode, bobNode, "alice's paper".outputStateAndRef()).sellerResult
|
val aliceFuture = runBuyerAndSeller(notaryNode, aliceNode, bobNode, "alice's paper".outputStateAndRef()).sellerResult
|
||||||
|
|
||||||
@ -221,7 +225,7 @@ class TwoPartyTradeFlowTests {
|
|||||||
return object : MockNetwork.MockNode(config, network, networkMapAddr, advertisedServices, id, overrideServices, entropyRoot) {
|
return object : MockNetwork.MockNode(config, network, networkMapAddr, advertisedServices, id, overrideServices, entropyRoot) {
|
||||||
// That constructs the storage service object in a customised way ...
|
// That constructs the storage service object in a customised way ...
|
||||||
override fun constructStorageService(
|
override fun constructStorageService(
|
||||||
attachments: NodeAttachmentService,
|
attachments: AttachmentStorage,
|
||||||
transactionStorage: TransactionStorage,
|
transactionStorage: TransactionStorage,
|
||||||
stateMachineRecordedTransactionMappingStorage: StateMachineRecordedTransactionMappingStorage
|
stateMachineRecordedTransactionMappingStorage: StateMachineRecordedTransactionMappingStorage
|
||||||
): StorageServiceImpl {
|
): StorageServiceImpl {
|
||||||
@ -248,15 +252,19 @@ class TwoPartyTradeFlowTests {
|
|||||||
it.write("Our commercial paper is top notch stuff".toByteArray())
|
it.write("Our commercial paper is top notch stuff".toByteArray())
|
||||||
it.closeEntry()
|
it.closeEntry()
|
||||||
}
|
}
|
||||||
val attachmentID = attachment(ByteArrayInputStream(stream.toByteArray()))
|
val attachmentID = databaseTransaction(aliceNode.database) {
|
||||||
|
attachment(ByteArrayInputStream(stream.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
val extraKey = bobNode.keyManagement.freshKey()
|
val extraKey = bobNode.keyManagement.freshKey()
|
||||||
val bobsFakeCash = fillUpForBuyer(false, extraKey.public.composite,
|
val bobsFakeCash = fillUpForBuyer(false, extraKey.public.composite,
|
||||||
DUMMY_CASH_ISSUER.party,
|
DUMMY_CASH_ISSUER.party,
|
||||||
notaryNode.info.notaryIdentity).second
|
notaryNode.info.notaryIdentity).second
|
||||||
val bobsSignedTxns = insertFakeTransactions(bobsFakeCash, bobNode, notaryNode, bobNode.services.legalIdentityKey, extraKey)
|
val bobsSignedTxns = insertFakeTransactions(bobsFakeCash, bobNode, notaryNode, bobNode.services.legalIdentityKey, extraKey)
|
||||||
val alicesFakePaper = fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
val alicesFakePaper = databaseTransaction(aliceNode.database) {
|
||||||
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, attachmentID, notaryNode.info.notaryIdentity).second
|
fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
||||||
|
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, attachmentID, notaryNode.info.notaryIdentity).second
|
||||||
|
}
|
||||||
val alicesSignedTxns = insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
val alicesSignedTxns = insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
||||||
|
|
||||||
net.runNetwork() // Clear network map registration messages
|
net.runNetwork() // Clear network map registration messages
|
||||||
@ -283,10 +291,12 @@ class TwoPartyTradeFlowTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bob has downloaded the attachment.
|
// Bob has downloaded the attachment.
|
||||||
bobNode.storage.attachments.openAttachment(attachmentID)!!.openAsJAR().use {
|
databaseTransaction(bobNode.database) {
|
||||||
it.nextJarEntry
|
bobNode.storage.attachments.openAttachment(attachmentID)!!.openAsJAR().use {
|
||||||
val contents = it.reader().readText()
|
it.nextJarEntry
|
||||||
assertTrue(contents.contains("Our commercial paper is top notch stuff"))
|
val contents = it.reader().readText()
|
||||||
|
assertTrue(contents.contains("Our commercial paper is top notch stuff"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,7 +337,7 @@ class TwoPartyTradeFlowTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `track() works`() {
|
fun `track works`() {
|
||||||
|
|
||||||
val notaryNode = net.createNotaryNode(null, DUMMY_NOTARY.name)
|
val notaryNode = net.createNotaryNode(null, DUMMY_NOTARY.name)
|
||||||
val aliceNode = makeNodeWithTracking(notaryNode.info.address, ALICE.name)
|
val aliceNode = makeNodeWithTracking(notaryNode.info.address, ALICE.name)
|
||||||
@ -343,14 +353,20 @@ class TwoPartyTradeFlowTests {
|
|||||||
it.write("Our commercial paper is top notch stuff".toByteArray())
|
it.write("Our commercial paper is top notch stuff".toByteArray())
|
||||||
it.closeEntry()
|
it.closeEntry()
|
||||||
}
|
}
|
||||||
val attachmentID = attachment(ByteArrayInputStream(stream.toByteArray()))
|
val attachmentID = databaseTransaction(aliceNode.database) {
|
||||||
|
attachment(ByteArrayInputStream(stream.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
val bobsFakeCash = fillUpForBuyer(false, bobNode.keyManagement.freshKey().public.composite,
|
val bobsFakeCash = fillUpForBuyer(false, bobNode.keyManagement.freshKey().public.composite,
|
||||||
DUMMY_CASH_ISSUER.party,
|
DUMMY_CASH_ISSUER.party,
|
||||||
notaryNode.info.notaryIdentity).second
|
notaryNode.info.notaryIdentity).second
|
||||||
insertFakeTransactions(bobsFakeCash, bobNode, notaryNode)
|
insertFakeTransactions(bobsFakeCash, bobNode, notaryNode)
|
||||||
val alicesFakePaper = fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
|
||||||
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, attachmentID, notaryNode.info.notaryIdentity).second
|
val alicesFakePaper = databaseTransaction(aliceNode.database) {
|
||||||
|
fillUpForSeller(false, aliceNode.info.legalIdentity.owningKey,
|
||||||
|
1200.DOLLARS `issued by` DUMMY_CASH_ISSUER, attachmentID, notaryNode.info.notaryIdentity).second
|
||||||
|
}
|
||||||
|
|
||||||
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
||||||
|
|
||||||
net.runNetwork() // Clear network map registration messages
|
net.runNetwork() // Clear network map registration messages
|
||||||
@ -444,8 +460,10 @@ class TwoPartyTradeFlowTests {
|
|||||||
|
|
||||||
val bobsBadCash = fillUpForBuyer(bobError, bobKey.public.composite, DUMMY_CASH_ISSUER.party,
|
val bobsBadCash = fillUpForBuyer(bobError, bobKey.public.composite, DUMMY_CASH_ISSUER.party,
|
||||||
notaryNode.info.notaryIdentity).second
|
notaryNode.info.notaryIdentity).second
|
||||||
val alicesFakePaper = fillUpForSeller(aliceError, aliceNode.info.legalIdentity.owningKey,
|
val alicesFakePaper = databaseTransaction(aliceNode.database) {
|
||||||
1200.DOLLARS `issued by` issuer, null, notaryNode.info.notaryIdentity).second
|
fillUpForSeller(aliceError, aliceNode.info.legalIdentity.owningKey,
|
||||||
|
1200.DOLLARS `issued by` issuer, null, notaryNode.info.notaryIdentity).second
|
||||||
|
}
|
||||||
|
|
||||||
insertFakeTransactions(bobsBadCash, bobNode, notaryNode, bobKey)
|
insertFakeTransactions(bobsBadCash, bobNode, notaryNode, bobKey)
|
||||||
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
insertFakeTransactions(alicesFakePaper, aliceNode, notaryNode, aliceKey)
|
||||||
@ -468,6 +486,7 @@ class TwoPartyTradeFlowTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun insertFakeTransactions(
|
private fun insertFakeTransactions(
|
||||||
wtxToSign: List<WireTransaction>,
|
wtxToSign: List<WireTransaction>,
|
||||||
node: AbstractNode,
|
node: AbstractNode,
|
||||||
|
@ -5,18 +5,27 @@ import com.google.common.jimfs.Configuration
|
|||||||
import com.google.common.jimfs.Jimfs
|
import com.google.common.jimfs.Jimfs
|
||||||
import net.corda.core.crypto.SecureHash
|
import net.corda.core.crypto.SecureHash
|
||||||
import net.corda.core.crypto.sha256
|
import net.corda.core.crypto.sha256
|
||||||
import net.corda.core.div
|
|
||||||
import net.corda.core.read
|
import net.corda.core.read
|
||||||
import net.corda.core.readAll
|
import net.corda.core.readAll
|
||||||
|
import net.corda.core.utilities.LogHelper
|
||||||
import net.corda.core.write
|
import net.corda.core.write
|
||||||
|
import net.corda.node.services.database.RequeryConfiguration
|
||||||
import net.corda.node.services.persistence.NodeAttachmentService
|
import net.corda.node.services.persistence.NodeAttachmentService
|
||||||
|
import net.corda.node.services.persistence.schemas.AttachmentEntity
|
||||||
|
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
||||||
|
import net.corda.node.utilities.configureDatabase
|
||||||
|
import net.corda.testing.node.makeTestDataSourceProperties
|
||||||
|
import org.jetbrains.exposed.sql.Database
|
||||||
|
import org.jetbrains.exposed.sql.transactions.TransactionManager
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.Closeable
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.file.FileAlreadyExistsException
|
import java.nio.file.FileAlreadyExistsException
|
||||||
import java.nio.file.FileSystem
|
import java.nio.file.FileSystem
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.StandardOpenOption.WRITE
|
import java.util.*
|
||||||
import java.util.jar.JarEntry
|
import java.util.jar.JarEntry
|
||||||
import java.util.jar.JarOutputStream
|
import java.util.jar.JarOutputStream
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@ -26,18 +35,35 @@ import kotlin.test.assertNull
|
|||||||
class NodeAttachmentStorageTest {
|
class NodeAttachmentStorageTest {
|
||||||
// Use an in memory file system for testing attachment storage.
|
// Use an in memory file system for testing attachment storage.
|
||||||
lateinit var fs: FileSystem
|
lateinit var fs: FileSystem
|
||||||
|
lateinit var dataSource: Closeable
|
||||||
|
lateinit var database: Database
|
||||||
|
lateinit var dataSourceProperties: Properties
|
||||||
|
lateinit var configuration: RequeryConfiguration
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
|
LogHelper.setLevel(PersistentUniquenessProvider::class)
|
||||||
|
|
||||||
|
dataSourceProperties = makeTestDataSourceProperties()
|
||||||
|
val dataSourceAndDatabase = configureDatabase(dataSourceProperties)
|
||||||
|
dataSource = dataSourceAndDatabase.first
|
||||||
|
database = dataSourceAndDatabase.second
|
||||||
|
|
||||||
|
configuration = RequeryConfiguration(dataSourceProperties)
|
||||||
fs = Jimfs.newFileSystem(Configuration.unix())
|
fs = Jimfs.newFileSystem(Configuration.unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
TransactionManager.current().close()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `insert and retrieve`() {
|
fun `insert and retrieve`() {
|
||||||
val testJar = makeTestJar()
|
val testJar = makeTestJar()
|
||||||
val expectedHash = testJar.readAll().sha256()
|
val expectedHash = testJar.readAll().sha256()
|
||||||
|
|
||||||
val storage = NodeAttachmentService(fs.getPath("/"), MetricRegistry())
|
val storage = NodeAttachmentService(fs.getPath("/"), dataSourceProperties, MetricRegistry())
|
||||||
val id = testJar.read { storage.importAttachment(it) }
|
val id = testJar.read { storage.importAttachment(it) }
|
||||||
assertEquals(expectedHash, id)
|
assertEquals(expectedHash, id)
|
||||||
|
|
||||||
@ -49,31 +75,49 @@ class NodeAttachmentStorageTest {
|
|||||||
val e2 = stream.nextJarEntry!!
|
val e2 = stream.nextJarEntry!!
|
||||||
assertEquals("test2.txt", e2.name)
|
assertEquals("test2.txt", e2.name)
|
||||||
assertEquals(stream.readBytes().toString(Charset.defaultCharset()), "Some more useful content")
|
assertEquals(stream.readBytes().toString(Charset.defaultCharset()), "Some more useful content")
|
||||||
|
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
storage.openAttachment(id)!!.openAsJAR().use {
|
||||||
|
it.nextJarEntry
|
||||||
|
it.readBytes()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `duplicates not allowed`() {
|
fun `duplicates not allowed`() {
|
||||||
|
|
||||||
val testJar = makeTestJar()
|
val testJar = makeTestJar()
|
||||||
val storage = NodeAttachmentService(fs.getPath("/"), MetricRegistry())
|
val storage = NodeAttachmentService(fs.getPath("/"), dataSourceProperties, MetricRegistry())
|
||||||
testJar.read { storage.importAttachment(it) }
|
testJar.read {
|
||||||
|
storage.importAttachment(it)
|
||||||
|
}
|
||||||
assertFailsWith<FileAlreadyExistsException> {
|
assertFailsWith<FileAlreadyExistsException> {
|
||||||
testJar.read { storage.importAttachment(it) }
|
testJar.read {
|
||||||
|
storage.importAttachment(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `corrupt entry throws exception`() {
|
fun `corrupt entry throws exception`() {
|
||||||
val testJar = makeTestJar()
|
val testJar = makeTestJar()
|
||||||
val storage = NodeAttachmentService(fs.getPath("/"), MetricRegistry())
|
val storage = NodeAttachmentService(fs.getPath("/"), dataSourceProperties, MetricRegistry())
|
||||||
val id = testJar.read { storage.importAttachment(it) }
|
val id = testJar.read { storage.importAttachment(it) }
|
||||||
|
|
||||||
// Corrupt the file in the store.
|
// Corrupt the file in the store.
|
||||||
fs.getPath("/", id.toString()).write(options = WRITE) { it.write("arggghhhh".toByteArray()) }
|
val bytes = testJar.readAll();
|
||||||
|
val corruptBytes = "arggghhhh".toByteArray()
|
||||||
|
System.arraycopy(corruptBytes, 0, bytes, 0, corruptBytes.size)
|
||||||
|
val corruptAttachment = AttachmentEntity()
|
||||||
|
corruptAttachment.attId = id
|
||||||
|
corruptAttachment.content = bytes
|
||||||
|
storage.session.update(corruptAttachment)
|
||||||
|
|
||||||
val e = assertFailsWith<NodeAttachmentService.OnDiskHashMismatch> {
|
val e = assertFailsWith<NodeAttachmentService.HashMismatchException> {
|
||||||
storage.openAttachment(id)!!.open().use { it.readBytes() }
|
storage.openAttachment(id)!!.open().use { it.readBytes() }
|
||||||
}
|
}
|
||||||
assertEquals(e.file, storage.storePath / id.toString())
|
assertEquals(e.expected, id)
|
||||||
|
|
||||||
// But if we skip around and read a single entry, no exception is thrown.
|
// But if we skip around and read a single entry, no exception is thrown.
|
||||||
storage.openAttachment(id)!!.openAsJAR().use {
|
storage.openAttachment(id)!!.openAsJAR().use {
|
||||||
|
@ -14,7 +14,6 @@ import net.corda.core.utilities.Emoji
|
|||||||
import net.corda.core.utilities.ProgressTracker
|
import net.corda.core.utilities.ProgressTracker
|
||||||
import net.corda.core.utilities.unwrap
|
import net.corda.core.utilities.unwrap
|
||||||
import net.corda.flows.TwoPartyTradeFlow
|
import net.corda.flows.TwoPartyTradeFlow
|
||||||
import net.corda.node.services.persistence.NodeAttachmentService
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -28,7 +27,7 @@ class BuyerFlow(val otherParty: Party,
|
|||||||
init {
|
init {
|
||||||
// Buyer will fetch the attachment from the seller automatically when it resolves the transaction.
|
// Buyer will fetch the attachment from the seller automatically when it resolves the transaction.
|
||||||
// For demo purposes just extract attachment jars when saved to disk, so the user can explore them.
|
// For demo purposes just extract attachment jars when saved to disk, so the user can explore them.
|
||||||
val attachmentsPath = (services.storageService.attachments as NodeAttachmentService).let {
|
val attachmentsPath = (services.storageService.attachments).let {
|
||||||
it.automaticallyExtractAttachments = true
|
it.automaticallyExtractAttachments = true
|
||||||
it.storePath
|
it.storePath
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,8 @@ import java.io.ByteArrayInputStream
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
import java.security.KeyPair
|
import java.security.KeyPair
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
@ -98,6 +100,8 @@ class MockKeyManagementService(vararg initialKeys: KeyPair) : SingletonSerialize
|
|||||||
|
|
||||||
class MockAttachmentStorage : AttachmentStorage {
|
class MockAttachmentStorage : AttachmentStorage {
|
||||||
val files = HashMap<SecureHash, ByteArray>()
|
val files = HashMap<SecureHash, ByteArray>()
|
||||||
|
override var automaticallyExtractAttachments = false
|
||||||
|
override var storePath = Paths.get("")
|
||||||
|
|
||||||
override fun openAttachment(id: SecureHash): Attachment? {
|
override fun openAttachment(id: SecureHash): Attachment? {
|
||||||
val f = files[id] ?: return null
|
val f = files[id] ?: return null
|
||||||
|
Loading…
x
Reference in New Issue
Block a user