CORDA-3584: Now cope with 2 contract jars with same hash but different name (#5952)

* CORDA-3484: Now cope with 2 contract jars with same hash but different name, we just select one and use that.

* ENT-3584: Contract jars are now generated on the fly.

* CORDA-3584: Reverted changes to CordappProviderImpl. Exception is raised if node started with multiple jars with same hash.

* ENT-3584: Fixing test failure.

* CORDA-3584: Switch to test extension method instead of reflection to access internal member.

* ENT-3584: Address review comment. Dont fully qualify exception.

* CORDA-3584: Address review comment and converted lazy to a resettable one.

* CORDA-3584: Removed unused logger.

* CORDA-3584: Fixed visibility.

* CORDA-3584: Removed synchronized

* CORDA-3584: Removed CordappResolver

* CORDA-3584: Reverted change in gradle file and fixed test.

* CORDA-3584: Removed V3 from test description as it wasn't actually V3 specific.

* CORDA-3584: Address review comment. Let classes be garbage collected.
This commit is contained in:
Adel El-Beik 2020-02-25 11:18:02 +00:00 committed by GitHub
parent 2c9c2985c0
commit fe625d0f37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 81 additions and 263 deletions

View File

@ -13,7 +13,6 @@ import net.corda.core.identity.CordaX500Name
import net.corda.core.identity.Party
import net.corda.core.identity.PartyAndCertificate
import net.corda.core.internal.VisibleForTesting
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.internal.warnOnce
import net.corda.core.node.ServiceHub
import net.corda.core.serialization.CordaSerializable
@ -95,7 +94,7 @@ private constructor(private val otherSideSession: FlowSession?,
override fun call(): LinkedHashMap<Party, AnonymousParty> {
val session = if (otherParty != null && otherParty != otherSideSession?.counterparty) {
logger.warnOnce("The current usage of SwapIdentitiesFlow is unsafe. Please consider upgrading your CorDapp to use " +
"SwapIdentitiesFlow with FlowSessions. (${CordappResolver.currentCordapp?.info})")
"SwapIdentitiesFlow with FlowSessions. (${serviceHub.getAppContext().cordapp.info})")
initiateFlow(otherParty)
} else {
otherSideSession!!

View File

@ -4,7 +4,6 @@ import com.natpryce.hamkrest.and
import com.natpryce.hamkrest.assertion.assertThat
import net.corda.core.flows.FinalityFlow
import net.corda.core.identity.Party
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
import net.corda.core.utilities.getOrThrow
@ -25,7 +24,8 @@ class FinalityFlowTests : WithFinality {
private val CHARLIE = TestIdentity(CHARLIE_NAME, 90).party
}
override val mockNet = InternalMockNetwork(cordappsForAllNodes = listOf(FINANCE_CONTRACTS_CORDAPP, enclosedCordapp()))
override val mockNet = InternalMockNetwork(cordappsForAllNodes = listOf(FINANCE_CONTRACTS_CORDAPP, enclosedCordapp(),
CustomCordapp(targetPlatformVersion = 3, classes = setOf(FinalityFlow::class.java))))
private val aliceNode = makeNode(ALICE_NAME)
@ -60,11 +60,8 @@ class FinalityFlowTests : WithFinality {
fun `allow use of the old API if the CorDapp target version is 3`() {
val oldBob = createBob(cordapps = listOf(tokenOldCordapp()))
val stx = aliceNode.issuesCashTo(oldBob)
val resultFuture = CordappResolver.withTestCordapp(targetPlatformVersion = 3) {
@Suppress("DEPRECATION")
aliceNode.startFlowAndRunNetwork(FinalityFlow(stx)).resultFuture
}
resultFuture.getOrThrow()
@Suppress("DEPRECATION")
aliceNode.startFlowAndRunNetwork(FinalityFlow(stx)).resultFuture.getOrThrow()
assertThat(oldBob.services.validatedTransactions.getTransaction(stx.id)).isNotNull()
}

View File

@ -5,7 +5,6 @@ import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.isFulfilledBy
import net.corda.core.identity.Party
import net.corda.core.identity.groupAbstractPartyByWellKnownParty
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.internal.pushToLoggingContext
import net.corda.core.internal.warnOnce
import net.corda.core.node.StatesToRecord
@ -136,7 +135,7 @@ class FinalityFlow private constructor(val transaction: SignedTransaction,
override fun call(): SignedTransaction {
if (!newApi) {
logger.warnOnce("The current usage of FinalityFlow is unsafe. Please consider upgrading your CorDapp to use " +
"FinalityFlow with FlowSessions. (${CordappResolver.currentCordapp?.info})")
"FinalityFlow with FlowSessions. (${serviceHub.getAppContext().cordapp.info})")
} else {
require(sessions.none { serviceHub.myInfo.isLegalIdentity(it.counterparty) }) {
"Do not provide flow sessions for the local node. FinalityFlow will record the notarised transaction locally."

View File

@ -1,133 +0,0 @@
package net.corda.core.internal.cordapp
import net.corda.core.cordapp.Cordapp
import net.corda.core.internal.PLATFORM_VERSION
import net.corda.core.internal.VisibleForTesting
import net.corda.core.internal.warnOnce
import net.corda.core.utilities.loggerFor
import java.util.concurrent.ConcurrentHashMap
/**
* Provides a way to acquire information about the calling CorDapp.
*/
object CordappResolver {
private val logger = loggerFor<CordappResolver>()
private val cordappClasses: ConcurrentHashMap<String, Set<Cordapp>> = ConcurrentHashMap()
private val insideInMemoryTest: Boolean by lazy { insideInMemoryTest() }
// TODO Use the StackWalker API once we migrate to Java 9+
private var cordappResolver: () -> Cordapp? = {
Exception().stackTrace
.mapNotNull { cordappClasses[it.className] }
// in case there are multiple classes matched, we select the first one having a single CorDapp registered against it.
.firstOrNull { it.size == 1 }
// otherwise we return null, signalling we cannot reliably determine the current CorDapp.
?.single()
}
/**
* Associates class names with CorDapps or logs a warning when a CorDapp is already registered for a given class.
* This could happen when trying to run different versions of the same CorDapp on the same node.
*
* @throws IllegalStateException when multiple CorDapps are registered for the same contract class,
* since this can lead to undefined behaviour.
*/
@Synchronized
fun register(cordapp: Cordapp) {
val contractClasses = cordapp.contractClassNames.toSet()
val existingClasses = cordappClasses.keys
val classesToRegister = cordapp.cordappClasses.toSet()
val notAlreadyRegisteredClasses = classesToRegister - existingClasses
val alreadyRegistered= HashMap(cordappClasses).apply { keys.retainAll(classesToRegister) }
notAlreadyRegisteredClasses.forEach { cordappClasses[it] = setOf(cordapp) }
for ((registeredClassName, registeredCordapps) in alreadyRegistered) {
val duplicateCordapps = registeredCordapps.filter { it.jarHash == cordapp.jarHash }.toSet()
if (duplicateCordapps.isNotEmpty()) {
logger.warnOnce("The CorDapp (name: ${cordapp.info.shortName}, file: ${cordapp.name}) " +
"is installed multiple times on the node. The following files correspond to the exact same content: " +
"${duplicateCordapps.map { it.name }}")
continue
}
// During in-memory tests, the spawned nodes share the same CordappResolver, so detected conflicts can be spurious.
if (registeredClassName in contractClasses && !insideInMemoryTest) {
throw IllegalStateException("More than one CorDapp installed on the node for contract $registeredClassName. " +
"Please remove the previous version when upgrading to a new version.")
}
cordappClasses[registeredClassName] = registeredCordapps + cordapp
}
}
private fun insideInMemoryTest(): Boolean {
return Exception().stackTrace.any {
it.className.startsWith("net.corda.testing.node.internal.InternalMockNetwork") ||
it.className.startsWith("net.corda.testing.node.internal.InProcessNode") ||
it.className.startsWith("net.corda.testing.node.MockServices")
}
}
/*
* This should only be used when making a change that would break compatibility with existing CorDapps. The change
* can then be version-gated, meaning the old behaviour is used if the calling CorDapp's target version is lower
* than the platform version that introduces the new behaviour.
* In situations where a `[CordappProvider]` is available the CorDapp context should be obtained from there.
*
* @return Information about the CorDapp from which the invoker is called, null if called outside a CorDapp or the
* calling CorDapp cannot be reliably determined.
*/
val currentCordapp: Cordapp? get() = cordappResolver()
/**
* Returns the target version of the current calling CorDapp. Defaults to platform version 1 if there isn't one,
* assuming only basic platform capabilities.
*/
val currentTargetVersion: Int get() = currentCordapp?.targetPlatformVersion ?: 1
// A list of extra CorDapps added to the current CorDapps list for testing purposes.
private var extraCordappsForTesting = listOf<Cordapp>()
/**
* Return all the CorDapps that were involved in the call stack at the point the provided exception was generated.
*
* This is provided to allow splitting the cost of generating the exception and retrieving the CorDapps involved.
*/
fun cordappsFromException(exception: Exception): List<Cordapp> {
val apps = exception.stackTrace
.mapNotNull { cordappClasses[it.className] }
.flatten()
.distinct()
return (apps + extraCordappsForTesting)
}
/**
* Temporarily apply a fake CorDapp with the given parameters. For use in testing.
*/
@Synchronized
@VisibleForTesting
fun <T> withTestCordapp(minimumPlatformVersion: Int = 1,
targetPlatformVersion: Int = PLATFORM_VERSION,
extraApps: List<CordappImpl> = listOf(),
block: () -> T): T {
val currentResolver = cordappResolver
cordappResolver = {
CordappImpl.TEST_INSTANCE.copy(minimumPlatformVersion = minimumPlatformVersion, targetPlatformVersion = targetPlatformVersion)
}
extraCordappsForTesting = listOf(cordappResolver()!!) + extraApps
try {
return block()
} finally {
cordappResolver = currentResolver
extraCordappsForTesting = listOf()
}
}
@VisibleForTesting
internal fun clear() {
cordappClasses.clear()
}
}

View File

@ -1,92 +0,0 @@
package net.corda.core.internal.cordapp
import net.corda.core.crypto.SecureHash
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.lang.IllegalStateException
import kotlin.test.assertEquals
class CordappResolverTest {
@Before
@After
fun clearCordappInfoResolver() {
CordappResolver.clear()
}
@Test(timeout=300_000)
fun `the correct cordapp resolver is used after calling withCordappInfo`() {
val defaultTargetVersion = 222
CordappResolver.register(CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf(javaClass.name),
minimumPlatformVersion = 3,
targetPlatformVersion = defaultTargetVersion
))
assertEquals(defaultTargetVersion, CordappResolver.currentTargetVersion)
val expectedTargetVersion = 555
CordappResolver.withTestCordapp(targetPlatformVersion = expectedTargetVersion) {
val actualTargetVersion = CordappResolver.currentTargetVersion
assertEquals(expectedTargetVersion, actualTargetVersion)
}
assertEquals(defaultTargetVersion, CordappResolver.currentTargetVersion)
}
@Test(timeout=300_000)
fun `when the same cordapp is registered for the same class multiple times, the resolver deduplicates and returns it as the current one`() {
CordappResolver.register(CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf(javaClass.name),
minimumPlatformVersion = 3,
targetPlatformVersion = 222
))
CordappResolver.register(CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf(javaClass.name),
minimumPlatformVersion = 2,
targetPlatformVersion = 456
))
assertThat(CordappResolver.currentCordapp).isNotNull()
}
@Test(timeout=300_000)
fun `when different cordapps are registered for the same (non-contract) class, the resolver returns null`() {
CordappResolver.register(CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf("ContractClass1"),
minimumPlatformVersion = 3,
targetPlatformVersion = 222,
jarHash = SecureHash.randomSHA256()
))
CordappResolver.register(CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf("ContractClass2"),
minimumPlatformVersion = 2,
targetPlatformVersion = 456,
jarHash = SecureHash.randomSHA256()
))
assertThat(CordappResolver.currentCordapp).isNull()
}
@Test(timeout=300_000)
fun `when different cordapps are registered for the same (contract) class, the resolver throws an exception`() {
val firstCordapp = CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf(javaClass.name),
minimumPlatformVersion = 3,
targetPlatformVersion = 222,
jarHash = SecureHash.randomSHA256()
)
val secondCordapp = CordappImpl.TEST_INSTANCE.copy(
contractClassNames = listOf(javaClass.name),
minimumPlatformVersion = 2,
targetPlatformVersion = 456,
jarHash = SecureHash.randomSHA256()
)
CordappResolver.register(firstCordapp)
assertThatThrownBy { CordappResolver.register(secondCordapp) }
.isInstanceOf(IllegalStateException::class.java)
.hasMessageContaining("More than one CorDapp installed on the node for contract ${javaClass.name}. " +
"Please remove the previous version when upgrading to a new version.")
}
}

View File

@ -9,7 +9,6 @@ import net.corda.core.flows.*
import net.corda.core.internal.*
import net.corda.core.internal.cordapp.CordappImpl
import net.corda.core.internal.cordapp.CordappImpl.Companion.UNKNOWN_INFO
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.internal.cordapp.get
import net.corda.core.internal.notary.NotaryService
import net.corda.core.internal.notary.SinglePartyNotaryService
@ -30,6 +29,7 @@ import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Path
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.jar.JarInputStream
import java.util.jar.Manifest
import java.util.zip.ZipInputStream
@ -52,7 +52,7 @@ class JarScanningCordappLoader private constructor(private val cordappJarPaths:
logger.info("Loading CorDapps from ${cordappJarPaths.joinToString()}")
}
}
private val cordappClasses: ConcurrentHashMap<String, Set<Cordapp>> = ConcurrentHashMap()
override val cordapps: List<CordappImpl> by lazy { loadCordapps() + extraCordapps }
override val appClassLoader: URLClassLoader = URLClassLoader(cordappJarPaths.stream().map { it.url }.toTypedArray(), javaClass.classLoader)
@ -128,10 +128,35 @@ class JarScanningCordappLoader private constructor(private val cordappJarPaths:
}
}
}
cordapps.forEach(CordappResolver::register)
cordapps.forEach(::register)
return cordapps
}
private fun register(cordapp: Cordapp) {
val contractClasses = cordapp.contractClassNames.toSet()
val existingClasses = cordappClasses.keys
val classesToRegister = cordapp.cordappClasses.toSet()
val notAlreadyRegisteredClasses = classesToRegister - existingClasses
val alreadyRegistered= HashMap(cordappClasses).apply { keys.retainAll(classesToRegister) }
notAlreadyRegisteredClasses.forEach { cordappClasses[it] = setOf(cordapp) }
for ((registeredClassName, registeredCordapps) in alreadyRegistered) {
val duplicateCordapps = registeredCordapps.filter { it.jarHash == cordapp.jarHash }.toSet()
if (duplicateCordapps.isNotEmpty()) {
throw IllegalStateException("The CorDapp (name: ${cordapp.info.shortName}, file: ${cordapp.name}) " +
"is installed multiple times on the node. The following files correspond to the exact same content: " +
"${duplicateCordapps.map { it.name }}")
}
if (registeredClassName in contractClasses) {
throw IllegalStateException("More than one CorDapp installed on the node for contract $registeredClassName. " +
"Please remove the previous version when upgrading to a new version.")
}
cordappClasses[registeredClassName] = registeredCordapps + cordapp
}
}
private fun RestrictedScanResult.toCordapp(url: RestrictedURL): CordappImpl {
val manifest: Manifest? = url.url.openStream().use { JarInputStream(it).manifest }
val info = parseCordappInfo(manifest, CordappImpl.jarName(url.url))

View File

@ -5,7 +5,8 @@ import com.typesafe.config.ConfigFactory
import net.corda.core.node.services.AttachmentId
import net.corda.core.node.services.AttachmentStorage
import net.corda.node.VersionInfo
import net.corda.testing.common.internal.testNetworkParameters
import net.corda.testing.core.internal.ContractJarTestUtils
import net.corda.testing.core.internal.SelfCleaningDir
import net.corda.testing.internal.MockCordappConfigProvider
import net.corda.testing.services.MockAttachmentStorage
import org.assertj.core.api.Assertions.assertThat
@ -14,7 +15,9 @@ import org.junit.Before
import org.junit.Test
import java.io.File
import java.io.FileOutputStream
import java.lang.IllegalStateException
import java.net.URL
import java.nio.file.Files
import java.util.jar.JarOutputStream
import java.util.zip.Deflater.NO_COMPRESSION
import java.util.zip.ZipEntry
@ -56,7 +59,6 @@ class CordappProviderImplTests {
}
private lateinit var attachmentStore: AttachmentStorage
private val whitelistedContractImplementations = testNetworkParameters().whitelistedContractImplementations
@Before
fun setup() {
@ -189,6 +191,33 @@ class CordappProviderImplTests {
assertThat(fixedIDs).containsExactlyInAnyOrder(ID2, ID4)
}
@Test(timeout=300_000)
fun `test an exception is raised when we have two jars with the same hash`() {
SelfCleaningDir().use { file ->
val jarAndSigner = ContractJarTestUtils.makeTestSignedContractJar(file.path, "com.example.MyContract")
val signedJarPath = jarAndSigner.first
val duplicateJarPath = signedJarPath.parent.resolve("duplicate-" + signedJarPath.fileName)
Files.copy(signedJarPath, duplicateJarPath)
assertFailsWith<IllegalStateException> {
newCordappProvider(signedJarPath.toUri().toURL(), duplicateJarPath.toUri().toURL())
}
}
}
@Test(timeout=300_000)
fun `test an exception is raised when two jars share a contract`() {
SelfCleaningDir().use { file ->
val jarA = ContractJarTestUtils.makeTestContractJar(file.path, listOf("com.example.MyContract", "com.example.AnotherContractForA"), generateManifest = false, jarFileName = "sampleA.jar")
val jarB = ContractJarTestUtils.makeTestContractJar(file.path, listOf("com.example.MyContract", "com.example.AnotherContractForB"), generateManifest = false, jarFileName = "sampleB.jar")
assertFailsWith<IllegalStateException> {
newCordappProvider(jarA.toUri().toURL(), jarB.toUri().toURL())
}
}
}
private fun File.writeFixupRules(vararg lines: String): File {
JarOutputStream(FileOutputStream(this)).use { jar ->
jar.setMethod(DEFLATED)

View File

@ -5,7 +5,6 @@ import net.corda.core.contracts.TransactionVerificationException
import net.corda.core.crypto.SecureHash
import net.corda.core.flows.FinalityFlow
import net.corda.core.flows.StateMachineRunId
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.toFuture
import net.corda.core.transactions.SignedTransaction
import net.corda.core.transactions.TransactionBuilder
@ -35,7 +34,8 @@ class FinalityHandlerTest {
fun `sent to flow hospital on error and attempted retry on node restart`() {
// Setup a network where only Alice has the finance CorDapp and it sends a cash tx to Bob who doesn't have the
// CorDapp. Bob's FinalityHandler will error when validating the tx.
val alice = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME, additionalCordapps = FINANCE_CORDAPPS))
val alice = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME,
additionalCordapps = FINANCE_CORDAPPS + CustomCordapp(targetPlatformVersion = 3, classes = setOf(FinalityFlow::class.java))))
var bob = mockNet.createNode(InternalMockNodeParameters(
legalName = BOB_NAME,
@ -82,11 +82,9 @@ class FinalityHandlerTest {
}
private fun TestStartedNode.finaliseWithOldApi(stx: SignedTransaction): CordaFuture<SignedTransaction> {
return CordappResolver.withTestCordapp(targetPlatformVersion = 3) {
@Suppress("DEPRECATION")
services.startFlow(FinalityFlow(stx)).resultFuture.apply {
@Suppress("DEPRECATION")
return services.startFlow(FinalityFlow(stx)).resultFuture.apply {
mockNet.runNetwork()
}
}
}

View File

@ -2,7 +2,6 @@ package net.corda.node.services.vault
import co.paralleluniverse.fibers.Suspendable
import com.nhaarman.mockito_kotlin.argThat
import com.nhaarman.mockito_kotlin.doNothing
import com.nhaarman.mockito_kotlin.mock
import com.nhaarman.mockito_kotlin.whenever
import net.corda.core.contracts.*
@ -11,7 +10,6 @@ import net.corda.core.crypto.SecureHash
import net.corda.core.crypto.generateKeyPair
import net.corda.core.identity.*
import net.corda.core.internal.NotaryChangeTransactionBuilder
import net.corda.core.internal.cordapp.CordappResolver
import net.corda.core.internal.packageName
import net.corda.core.node.NotaryInfo
import net.corda.core.node.StatesToRecord
@ -887,7 +885,7 @@ class NodeVaultServiceTest {
}
@Test(timeout=300_000)
fun `V3 vault queries return all states by default`() {
fun `Vault queries return all states by default`() {
fun createTx(number: Int, vararg participants: Party): SignedTransaction {
return services.signInitialTransaction(TransactionBuilder(DUMMY_NOTARY).apply {
addOutputState(DummyState(number, participants.toList()), DummyContract.PROGRAM_ID)
@ -897,20 +895,18 @@ class NodeVaultServiceTest {
fun List<StateAndRef<DummyState>>.getNumbers() = map { it.state.data.magicNumber }.toSet()
CordappResolver.withTestCordapp(targetPlatformVersion = 3) {
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(1, megaCorp.party)))
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(2, miniCorp.party)))
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(3, miniCorp.party, megaCorp.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(4, miniCorp.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(5, bankOfCorda.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(6, megaCorp.party, bankOfCorda.party)))
services.recordTransactions(StatesToRecord.NONE, listOf(createTx(7, bankOfCorda.party)))
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(1, megaCorp.party)))
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(2, miniCorp.party)))
services.recordTransactions(StatesToRecord.ONLY_RELEVANT, listOf(createTx(3, miniCorp.party, megaCorp.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(4, miniCorp.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(5, bankOfCorda.party)))
services.recordTransactions(StatesToRecord.ALL_VISIBLE, listOf(createTx(6, megaCorp.party, bankOfCorda.party)))
services.recordTransactions(StatesToRecord.NONE, listOf(createTx(7, bankOfCorda.party)))
// Test one.
// RelevancyStatus is ALL by default. This should return five states.
val resultOne = vaultService.queryBy<DummyState>().states.getNumbers()
assertEquals(setOf(1, 3, 4, 5, 6), resultOne)
}
// Test one.
// RelevancyStatus is ALL by default. This should return five states.
val resultOne = vaultService.queryBy<DummyState>().states.getNumbers()
assertEquals(setOf(1, 3, 4, 5, 6), resultOne)
// We should never see 2 or 7.
}