diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt index 8b38823145c..c1b40c7979b 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -21,6 +21,8 @@ import com.intellij.openapi.util.Key import com.intellij.openapi.util.SystemInfo import com.intellij.util.animation.consumer import com.intellij.util.io.await +import com.intellij.util.net.HttpConfigurable +import com.intellij.util.net.JdkProxyProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -29,6 +31,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout +import org.apache.http.client.utils.URIBuilder import org.eclipse.lsp4j.ClientCapabilities import org.eclipse.lsp4j.ClientInfo import org.eclipse.lsp4j.DidChangeConfigurationParams @@ -48,6 +51,7 @@ import org.slf4j.event.Level import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.core.utils.writeText import software.aws.toolkits.jetbrains.isDeveloperMode import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService @@ -60,6 +64,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtended import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDocumentServiceHandler import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler +import software.aws.toolkits.jetbrains.services.amazonq.profile.QDefaultServiceConfig import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import software.aws.toolkits.jetbrains.settings.LspSettings import java.io.IOException @@ -68,7 +73,10 @@ import java.io.PipedInputStream import java.io.PipedOutputStream import java.io.PrintWriter import java.io.StringWriter +import java.net.Proxy +import java.net.URI import java.nio.charset.StandardCharsets +import java.nio.file.Files import java.util.Collections import java.util.concurrent.Future import kotlin.time.Duration.Companion.seconds @@ -263,13 +271,49 @@ private class AmazonQServerInstance(private val project: Project, private val cs init { // will cause slow service init, but maybe fine for now. will not block UI since fetch/extract will be under background progress val artifact = runBlocking { service().fetchArtifact(project) }.toAbsolutePath() + + // more network calls + // make assumption that all requests will resolve to the same CA + // also terrible assumption that default endpoint is reachable + val qUri = URI(QDefaultServiceConfig.ENDPOINT) + val rtsTrustChain = TrustChainUtil.getTrustChain(qUri) + val extraCaCerts = Files.createTempFile("q-extra-ca", ".pem").apply { + writeText( + TrustChainUtil.certsToPem(rtsTrustChain) + ) + } + val node = if (SystemInfo.isWindows) "node.exe" else "node" val cmd = GeneralCommandLine( artifact.resolve(node).toString(), LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(), "--stdio", "--set-credentials-encryption-key", + ).withEnvironment( + buildMap { + put("NODE_EXTRA_CA_CERTS", extraCaCerts.toAbsolutePath().toString()) + + val proxy = JdkProxyProvider.getInstance().proxySelector.select(qUri) + // log if only socks proxy available + .firstOrNull { it.type() == Proxy.Type.HTTP } + + if (proxy != null) { + val address = proxy.address() + if (address is java.net.InetSocketAddress) { + put( + "HTTPS_PROXY", + URIBuilder("http://${address.hostName}:${address.port}").apply { + val login = HttpConfigurable.getInstance().proxyLogin + if (login != null) { + setUserInfo(login, HttpConfigurable.getInstance().plainProxyPassword) + } + }.build().toASCIIString() + ) + } + } + } ) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) launcherHandler = KillableColoredProcessHandler.Silent(cmd) val inputWrapper = LSPProcessListener() diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtil.kt new file mode 100644 index 00000000000..ff82c544307 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtil.kt @@ -0,0 +1,144 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.intellij.util.io.DigestUtil +import com.intellij.util.net.JdkProxyProvider +import com.intellij.util.net.ssl.CertificateManager +import org.apache.http.client.methods.RequestBuilder +import org.apache.http.conn.ssl.DefaultHostnameVerifier +import org.apache.http.impl.client.HttpClientBuilder +import org.apache.http.impl.client.SystemDefaultCredentialsProvider +import org.apache.http.impl.conn.SystemDefaultRoutePlanner +import org.jetbrains.annotations.TestOnly +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.net.URI +import java.security.KeyStore +import java.security.cert.CertPathBuilder +import java.security.cert.CertStore +import java.security.cert.Certificate +import java.security.cert.CollectionCertStoreParameters +import java.security.cert.PKIXBuilderParameters +import java.security.cert.PKIXCertPathBuilderResult +import java.security.cert.X509CertSelector +import java.security.cert.X509Certificate +import java.util.Base64 +import kotlin.collections.ifEmpty + +object TrustChainUtil { + private val LOG = getLogger() + + @TestOnly + fun resolveTrustChain(certs: Collection, trustAnchors: Collection) = resolveTrustChain( + certs, + keystoreFromCertificates(trustAnchors) + ) + + /** + * Build and validate the complete certificate chain + * @param certs The end-entity certificate + * @param trustAnchors The truststore containing trusted CA certificates + * @return The complete certificate chain + */ + fun resolveTrustChain(certs: Collection, trustAnchors: KeyStore): List { + try { + // Create the selector for the certificate + val selector = X509CertSelector() + selector.certificate = certs.first() + + // Create the parameters for path validation + val pkixParams = PKIXBuilderParameters(trustAnchors, selector) + + // Disable CRL checking since we just want to build the path + pkixParams.isRevocationEnabled = false + + // Create a CertStore containing the certificate we want to validate + val ccsp = CollectionCertStoreParameters(certs) + val certStore = CertStore.getInstance("Collection", ccsp) + pkixParams.addCertStore(certStore) + + // Get the certification path + val builder = CertPathBuilder.getInstance("PKIX") + val result = builder.build(pkixParams) as PKIXCertPathBuilderResult + val certPath = result.certPath + val chain = (certPath.certificates as List).toMutableList() + + // Add the trust anchor (root CA) to complete the chain + val trustAnchorCert = result.trustAnchor.trustedCert + if (trustAnchorCert != null) { + chain.add(trustAnchorCert) + } + + return chain + } catch (e: Exception) { + // Java PKIX is happy with leaf cert in certification path, but Node.JS will not respect in NODE_CA_CERTS + LOG.warn(e) { "Could not build trust anchor via CertPathBuilder? maybe user accepted leaf cert but not intermediate" } + + return emptyList() + } + } + + fun getTrustChain(uri: URI): List { + val proxyProvider = JdkProxyProvider.getInstance() + var peerCerts: Array = emptyArray() + val verifierDelegate = DefaultHostnameVerifier() + val client = HttpClientBuilder.create() + .setRoutePlanner(SystemDefaultRoutePlanner(proxyProvider.proxySelector)) + .setDefaultCredentialsProvider(SystemDefaultCredentialsProvider()) + .setSSLHostnameVerifier { hostname, sslSession -> + peerCerts = sslSession.peerCertificates + + verifierDelegate.verify(hostname, sslSession) + } + // prompt user via modal to accept certificate if needed; otherwise need to prompt separately prior to launching flare + .setSSLContext(CertificateManager.getInstance().sslContext) + + // client request will fail if user did not accept cert + client.build().use { it.execute(RequestBuilder.options(uri).build()) } + + val certificates = peerCerts as Array + + // java default + custom system + // excluding leaf cert for case where user has both leaf and issuing CA as trusted roots + val allAccepted = CertificateManager.getInstance().trustManager.acceptedIssuers.toSet() - certificates.first() + val ks = keystoreFromCertificates(allAccepted) + + // if this throws then there is a bug because it passed PKIX validation in apache client + val trustChain = try { + resolveTrustChain(certificates.toList(), ks) + } catch (e: Exception) { + // Java PKIX is happy with leaf cert in certification path, but Node.JS will not respect in NODE_CA_CERTS + LOG.warn(e) { "Passed Apache PKIX verification but could not build trust anchor via CertPathBuilder? maybe user accepted leaf cert but not root" } + emptyList() + } + + // if trust chain is empty, then somehow user only trusts the leaf cert??? + return trustChain.ifEmpty { + // so return the served certificate chain from the server and hope that works + certificates.toList() + } + } + + fun certsToPem(certs: List): String = + buildList { + certs.forEach { + add("-----BEGIN CERTIFICATE-----") + add(Base64.getMimeEncoder(64, System.lineSeparator().toByteArray()).encodeToString(it.encoded)) + add("-----END CERTIFICATE-----") + } + }.joinToString(separator = System.lineSeparator()) + + private fun keystoreFromCertificates(certificates: Collection): KeyStore { + val ks = KeyStore.getInstance(KeyStore.getDefaultType()) + ks.load(null, null) + certificates.forEachIndexed { index, cert -> + ks.setCertificateEntry( + cert.subjectX500Principal.toString() + "-" + DigestUtil.sha256Hex(cert.encoded), + cert + ) + } + return ks + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtilTest.kt new file mode 100644 index 00000000000..ae2c9cd765c --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/TrustChainUtilTest.kt @@ -0,0 +1,471 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.common.Slf4jNotifier +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.util.ExecUtil +import com.intellij.testFramework.ApplicationExtension +import com.intellij.util.net.ssl.CertificateManager +import org.assertj.core.api.Assertions.assertThat +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.extension.ExtendWith +import software.aws.toolkits.core.utils.outputStream +import software.aws.toolkits.core.utils.writeText +import java.math.BigInteger +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Date + +@ExtendWith(ApplicationExtension::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TrustChainUtilTest { + companion object { + private val certs = CertificateGenerator.generateCertificateChain() + } + + @BeforeEach + @AfterEach + fun clearCerts() { + CertificateManager.getInstance().customTrustManager.apply { + certificates.toList().forEach { removeCertificate(it) } + } + + assertThat(CertificateManager.getInstance().customTrustManager.certificates).isEmpty() + } + + @Test + fun `returns chain from server if leaf is trust anchor`() { + mockWithOptions( + { + it.keystorePath( + Files.createTempFile("certs", "jks") + .toAbsolutePath() + .apply { + CertificateGenerator.saveToKeyStore( + this, + certs.values.first(), + certs.keys.take(2).toTypedArray(), + ) + } + .toString() + ) + } + ) { + val trustChain = TrustChainUtil.getTrustChain(URI("https://localhost:${it.httpsPort()}")) + // leaf, intermediate + assertThat(trustChain) + .isEqualTo(certs.keys.take(2).toList()) + } + } + + @Test + fun `returns entire chain if CA is trust anchor`() { + CertificateManager.getInstance().customTrustManager.addCertificate(certs.keys.last()) + + mockWithOptions( + { + it.keystorePath( + Files.createTempFile("certs", "jks") + .toAbsolutePath() + .apply { + CertificateGenerator.saveToKeyStore( + this, + certs.values.first(), + certs.keys.take(2).toTypedArray(), + ) + } + .toString() + ) + } + ) { + val trustChain = TrustChainUtil.getTrustChain(URI("https://localhost:${it.httpsPort()}")) + // leaf, intermediate + assertThat(trustChain) + .isEqualTo(certs.keys.toList()) + } + } + + @Test + fun `returns empty if CA is trusted but does not provide intermediate`() { + val (leaf, _, root) = certs.keys.take(3) + assertThat( + TrustChainUtil.resolveTrustChain(listOf(leaf), listOf(root)) + ).isEmpty() + } + + @Test + fun `returns entire chain if CA is trusted and provides intermediate`() { + val (leaf, intermediate, root) = certs.keys.take(3) + assertThat( + TrustChainUtil.resolveTrustChain(listOf(leaf, intermediate), listOf(root)) + ).isEqualTo( + listOf(leaf, intermediate, root) + ) + } + + @Test + fun `returns empty if CA is not trusted`() { + val (leaf, intermediate) = certs.keys.take(2) + assertThat( + TrustChainUtil.resolveTrustChain( + listOf(leaf, intermediate), + listOf(CertificateManager.getInstance().trustManager.acceptedIssuers.first()) + ) + ).isEmpty() + } + + @Test + fun `node accepts full chain`() { + mockWithOptions( + { + it.keystorePath( + Files.createTempFile("certs", "jks") + .toAbsolutePath() + .apply { + CertificateGenerator.saveToKeyStore( + this, + certs.values.first(), + certs.keys.take(2).toTypedArray(), + ) + } + .toString() + ) + }, + { + val pemFile = Files.createTempFile("test", ".pem").apply { + writeText( + TrustChainUtil.certsToPem(certs.keys.toList()) + ) + } + + val output = ExecUtil.execAndGetOutput( + GeneralCommandLine( + "node", + "--use-bundled-ca", + Files.createTempFile("test", ".js").apply { writeText(nodeTest(it.httpsPort())) }.toAbsolutePath().toString(), + ).withEnvironment("NODE_EXTRA_CA_CERTS", pemFile.toAbsolutePath().toString()) + ) + + assertThat(output.exitCode).withFailMessage { "node validation failed: ${output.stdout}\n${output.stderr}" } + .isEqualTo(0) + } + ) + } + + @Test + fun `node does not accept intermediate only`() { + mockWithOptions( + { + it.keystorePath( + Files.createTempFile("certs", "jks") + .toAbsolutePath() + .apply { + CertificateGenerator.saveToKeyStore( + this, + certs.values.first(), + certs.keys.take(2).toTypedArray(), + ) + } + .toString() + ) + }, + { + val pemFile = Files.createTempFile("test", ".pem").apply { + writeText( + TrustChainUtil.certsToPem(certs.keys.take(2).toList()) + ) + } + + // node does not support partial chains + val output = ExecUtil.execAndGetOutput( + GeneralCommandLine( + "node", + "--use-bundled-ca", + Files.createTempFile("test", ".js").apply { writeText(nodeTest(it.httpsPort())) }.toAbsolutePath().toString(), + ).withEnvironment("NODE_EXTRA_CA_CERTS", pemFile.toAbsolutePath().toString()) + ) + + assertThat(output.exitCode).withFailMessage { "node validation succeeded instead of failed: ${output.stdout}\n${output.stderr}" } + .isEqualTo(1) + } + ) + } + + // language=JavaScript + private fun nodeTest(port: Int) = """ + const https = require("https"); + + async function main() { // Wrapped in async function for better error handling + try { + const options = { + host: "localhost", + port: $port, + path: "/", + requestCert: true, + rejectUnauthorized: true, + }; + + const req = https.get(options, (res) => { + console.log("Certificate authorized:", res.socket.authorized); + const cert = res.socket.getPeerCertificate(); + console.log("Certificate details:", cert); + process.exit(0) + }); + + req.on("error", (err) => { // Added error handling + console.error("Request error:", err); + process.exit(1) + }); + + req.end(); + } catch (error) { + console.error("Error:", error); + process.exit(1) + } + } + + main(); + """.trimIndent() + + private fun mockWithOptions(options: (WireMockConfiguration) -> Unit, runnable: (WireMockServer) -> Unit) { + val server = WireMockServer( + wireMockConfig() + .httpDisabled(true) + .http2TlsDisabled(true) + .keystoreType("jks") + .keystorePassword("changeit") + .keyManagerPassword("changeit") + .dynamicHttpsPort() + .notifier(Slf4jNotifier(true)) + .apply { options(this) } + ) + + try { + server.start() + + runnable(server) + } finally { + server.stop() + } + } +} + +object CertificateGenerator { + private const val KEY_ALGORITHM = "RSA" + private const val SIGNATURE_ALGORITHM = "SHA256withRSA" + private const val KEY_SIZE = 4096 + + fun generateCertificateChain(): Map { + // Generate Root CA + val rootKeyPair = generateKeyPair() + val rootCert = generateRootCertificate(rootKeyPair) + + // Generate Intermediate CA + val intermediateKeyPair = generateKeyPair() + val intermediateCert = generateIntermediateCertificate( + intermediateKeyPair, + rootCert, + rootKeyPair.private + ) + + // Generate Leaf Certificate + val leafKeyPair = generateKeyPair() + val leafCert = generateLeafCertificate( + leafKeyPair, + intermediateCert, + intermediateKeyPair.private + ) + + return linkedMapOf( + leafCert to leafKeyPair, + intermediateCert to intermediateKeyPair, + rootCert to rootKeyPair, + ) + } + + private fun generateKeyPair(): KeyPair = + KeyPairGenerator.getInstance(KEY_ALGORITHM).apply { + initialize(KEY_SIZE) + }.generateKeyPair() + + private fun generateRootCertificate(keyPair: KeyPair): X509Certificate { + val name = X500Name("CN=Root CA,O=My Organization,C=US") + + val now = Instant.now() + val startDate = Date.from(now) + val endDate = Date.from(now.plus(3650, ChronoUnit.DAYS)) // 10 years validity + + val certBuilder = JcaX509v3CertificateBuilder( + name, // issuer + BigInteger.valueOf(System.currentTimeMillis()), + startDate, + endDate, + name, // subject (same as issuer for root CA) + keyPair.public + ).apply { + // Add Extensions + addExtension( + Extension.basicConstraints, + true, + BasicConstraints(true) + ) + addExtension( + Extension.keyUsage, + true, + KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign) + ) + } + + // Sign the certificate + val signer = JcaContentSignerBuilder(SIGNATURE_ALGORITHM) + .build(keyPair.private) + + return JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)) + } + + private fun generateIntermediateCertificate( + intermediateKeyPair: KeyPair, + issuerCert: X509Certificate, + issuerPrivateKey: PrivateKey, + ): X509Certificate { + val subjectName = X500Name("CN=Intermediate CA,O=My Organization,C=US") + + val now = Instant.now() + val startDate = Date.from(now) + val endDate = Date.from(now.plus(1825, ChronoUnit.DAYS)) // 5 years validity + + val certBuilder = JcaX509v3CertificateBuilder( + issuerCert, + BigInteger.valueOf(System.currentTimeMillis()), + startDate, + endDate, + subjectName, + intermediateKeyPair.public + ).apply { + // Add Extensions + addExtension( + Extension.basicConstraints, + true, + // not allowed to issue sub-CA + BasicConstraints(0) + ) + addExtension( + Extension.keyUsage, + true, + KeyUsage(KeyUsage.keyCertSign or KeyUsage.cRLSign) + ) + } + + val signer = JcaContentSignerBuilder(SIGNATURE_ALGORITHM) + .build(issuerPrivateKey) + + return JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)) + } + + private fun generateLeafCertificate( + leafKeyPair: KeyPair, + issuerCert: X509Certificate, + issuerPrivateKey: PrivateKey, + ): X509Certificate { + val subjectName = X500Name("CN=localhost,O=My Organization,C=US") + + val now = Instant.now() + val startDate = Date.from(now) + val endDate = Date.from(now.plus(365, ChronoUnit.DAYS)) // 1 year validity + + val certBuilder = JcaX509v3CertificateBuilder( + issuerCert, + BigInteger.valueOf(System.currentTimeMillis()), + startDate, + endDate, + subjectName, + leafKeyPair.public + ).apply { + // Add Extensions + addExtension( + Extension.basicConstraints, + true, + BasicConstraints(false) + ) + addExtension( + Extension.keyUsage, + true, + KeyUsage(KeyUsage.digitalSignature or KeyUsage.keyEncipherment) + ) + + // Add Subject Alternative Names (SAN) + val subjectAltNames = GeneralNames( + arrayOf( + GeneralName(GeneralName.dNSName, "localhost"), + GeneralName(GeneralName.iPAddress, "127.0.0.1"), + GeneralName(GeneralName.iPAddress, "::1") + ) + ) + + addExtension( + Extension.subjectAlternativeName, + false, + subjectAltNames + ) + } + + val signer = JcaContentSignerBuilder(SIGNATURE_ALGORITHM) + .build(issuerPrivateKey) + + return JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)) + } + + fun saveToKeyStore( + keystorePath: Path, + leafKeyPair: KeyPair, + trustChain: Array, + ) { + val password = "changeit".toCharArray() + + // Create KeyStore + val keyStore = KeyStore.getInstance("JKS").apply { + load(null, password) + } + + // Store leaf certificate + keyStore.setKeyEntry( + "leaf", + leafKeyPair.private, + password, + trustChain + ) + + // Save to file + keystorePath.outputStream().use { fos -> + keyStore.store(fos, password) + } + } +}