diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3390054..5702c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: os: [ubuntu-latest] scala: [3.1.3, 2.12.16, 2.13.8] java: [temurin@8, temurin@11, temurin@17] - project: [rootJS, rootJVM] + project: [rootJS, rootJVM, rootNative] jsenv: [NodeJS, Chrome, Firefox] exclude: - scala: 3.1.3 @@ -46,6 +46,10 @@ jobs: java: temurin@11 - project: rootJS java: temurin@17 + - project: rootNative + java: temurin@11 + - project: rootNative + java: temurin@17 - scala: 3.1.3 jsenv: Chrome - scala: 3.1.3 @@ -56,8 +60,12 @@ jobs: jsenv: Firefox - project: rootJVM jsenv: Chrome + - project: rootNative + jsenv: Chrome - project: rootJVM jsenv: Firefox + - project: rootNative + jsenv: Firefox runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) @@ -142,6 +150,10 @@ jobs: if: matrix.project == 'rootJS' run: 'sbt ''project ${{ matrix.project }}'' ''++${{ matrix.scala }}'' ''set Global / useJSEnv := JSEnv.${{ matrix.jsenv }}'' Test/scalaJSLinkerResult' + - name: nativeLink + if: matrix.project == 'rootNative' + run: 'sbt ''project ${{ matrix.project }}'' ''++${{ matrix.scala }}'' ''set Global / useJSEnv := JSEnv.${{ matrix.jsenv }}'' Test/nativeLink' + - name: Test run: 'sbt ''project ${{ matrix.project }}'' ''++${{ matrix.scala }}'' ''set Global / useJSEnv := JSEnv.${{ matrix.jsenv }}'' test' @@ -163,11 +175,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p crypto/jvm/target target .js/target .jvm/target .native/target test-runtime/.jvm/target crypto/js/target test-runtime/.js/target project/target + run: mkdir -p crypto/jvm/target target .js/target crypto/native/target .jvm/target .native/target test-runtime/.jvm/target crypto/js/target test-runtime/.js/target test-runtime/.native/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar crypto/jvm/target target .js/target .jvm/target .native/target test-runtime/.jvm/target crypto/js/target test-runtime/.js/target project/target + run: tar cf targets.tar crypto/jvm/target target .js/target crypto/native/target .jvm/target .native/target test-runtime/.jvm/target crypto/js/target test-runtime/.js/target test-runtime/.native/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -272,6 +284,16 @@ jobs: tar xf targets.tar rm targets.tar + - name: Download target directories (3.1.3, NodeJS, rootNative) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3.1.3-NodeJS-rootNative + + - name: Inflate target directories (3.1.3, NodeJS, rootNative) + run: | + tar xf targets.tar + rm targets.tar + - name: Download target directories (2.12.16, NodeJS, rootJS) uses: actions/download-artifact@v2 with: @@ -292,6 +314,16 @@ jobs: tar xf targets.tar rm targets.tar + - name: Download target directories (2.12.16, NodeJS, rootNative) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-2.12.16-NodeJS-rootNative + + - name: Inflate target directories (2.12.16, NodeJS, rootNative) + run: | + tar xf targets.tar + rm targets.tar + - name: Download target directories (2.13.8, NodeJS, rootJS) uses: actions/download-artifact@v2 with: @@ -312,6 +344,16 @@ jobs: tar xf targets.tar rm targets.tar + - name: Download target directories (2.13.8, NodeJS, rootNative) + uses: actions/download-artifact@v2 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.8-NodeJS-rootNative + + - name: Inflate target directories (2.13.8, NodeJS, rootNative) + run: | + tar xf targets.tar + rm targets.tar + - name: Import signing key if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' run: echo $PGP_SECRET | base64 -di | gpg --import diff --git a/build.sbt b/build.sbt index 0b75b6b..23d27bf 100644 --- a/build.sbt +++ b/build.sbt @@ -54,7 +54,8 @@ ThisBuild / githubWorkflowBuildMatrixExclusions ++= { ThisBuild / githubWorkflowBuildMatrixExclusions ++= { for { jsenv <- jsenvs.tail - } yield MatrixExclude(Map("project" -> "rootJVM", "jsenv" -> jsenv)) + project <- List("rootJVM", "rootNative") + } yield MatrixExclude(Map("project" -> project, "jsenv" -> jsenv)) } lazy val useJSEnv = @@ -80,13 +81,13 @@ ThisBuild / Test / jsEnv := { val catsVersion = "2.8.0" val catsEffectVersion = "3.3.14" val scodecBitsVersion = "1.1.34" -val munitVersion = "0.7.29" -val munitCEVersion = "1.0.7" -val disciplineMUnitVersion = "1.0.9" +val munitVersion = "1.0.0-M6" +val munitCEVersion = "2.0.0-M3" +val disciplineMUnitVersion = "2.0.0-M3" lazy val root = tlCrossRootProject.aggregate(crypto, testRuntime) -lazy val crypto = crossProject(JSPlatform, JVMPlatform) +lazy val crypto = crossProject(JSPlatform, JVMPlatform, NativePlatform) .in(file("crypto")) .settings( name := "http4s-crypto", @@ -98,12 +99,16 @@ lazy val crypto = crossProject(JSPlatform, JVMPlatform) "org.typelevel" %%% "cats-laws" % catsVersion % Test, "org.typelevel" %%% "cats-effect" % catsEffectVersion % Test, "org.typelevel" %%% "discipline-munit" % disciplineMUnitVersion % Test, - "org.typelevel" %%% "munit-cats-effect-3" % munitCEVersion % Test + "org.typelevel" %%% "munit-cats-effect" % munitCEVersion % Test ) ) + .nativeSettings( + tlVersionIntroduced := List("2.12", "2.13", "3").map(_ -> "0.2.4").toMap, + unusedCompileDependenciesTest := {} + ) .dependsOn(testRuntime % Test) -lazy val testRuntime = crossProject(JSPlatform, JVMPlatform) +lazy val testRuntime = crossProject(JSPlatform, JVMPlatform, NativePlatform) .crossType(CrossType.Pure) .in(file("test-runtime")) .enablePlugins(BuildInfoPlugin, NoPublishPlugin) @@ -120,3 +125,9 @@ lazy val testRuntime = crossProject(JSPlatform, JVMPlatform) BuildInfoKey("runtime" -> useJSEnv.value.toString) ) ) + .nativeSettings( + buildInfoKeys := Seq( + BuildInfoKey("runtime" -> "Native") + ), + unusedCompileDependenciesTest := {} + ) diff --git a/crypto/js/src/main/scala/org/http4s/crypto/SecurityException.scala b/crypto/js-native/src/main/scala/org/http4s/crypto/SecurityException.scala similarity index 100% rename from crypto/js/src/main/scala/org/http4s/crypto/SecurityException.scala rename to crypto/js-native/src/main/scala/org/http4s/crypto/SecurityException.scala diff --git a/crypto/jvm/src/main/scala/org/http4s/crypto/SecureEqPlatform.scala b/crypto/jvm-native/src/main/scala/org/http4s/crypto/SecureEqPlatform.scala similarity index 100% rename from crypto/jvm/src/main/scala/org/http4s/crypto/SecureEqPlatform.scala rename to crypto/jvm-native/src/main/scala/org/http4s/crypto/SecureEqPlatform.scala diff --git a/crypto/native/src/main/scala/org/http4s/crypto/CryptoPlatform.scala b/crypto/native/src/main/scala/org/http4s/crypto/CryptoPlatform.scala new file mode 100644 index 0000000..297a2be --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/CryptoPlatform.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto + +import cats.effect.kernel.Sync + +private[crypto] trait CryptoCompanionPlatform { + implicit def forSync[F[_]: Sync]: Crypto[F] = + new UnsealedCrypto[F] { + override def hash: Hash[F] = Hash[F] + override def hmac: Hmac[F] = Hmac[F] + override def hmacKeyGen: HmacKeyGen[F] = HmacKeyGen[F] + } +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/HashPlatform.scala b/crypto/native/src/main/scala/org/http4s/crypto/HashPlatform.scala new file mode 100644 index 0000000..5cb5b59 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/HashPlatform.scala @@ -0,0 +1,56 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto + +import cats.ApplicativeThrow +import scodec.bits.ByteVector + +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +private[crypto] trait HashCompanionPlatform { + implicit def forApplicativeThrow[F[_]](implicit F: ApplicativeThrow[F]): Hash[F] = + new UnsealedHash[F] { + def digest(algorithm: HashAlgorithm, data: ByteVector): F[ByteVector] = + Zone { implicit z => + import HashAlgorithm._ + + val name = algorithm match { + case MD5 => c"MD5" + case SHA1 => c"SHA1" + case SHA256 => c"SHA256" + case SHA512 => c"SHA512" + } + + val `type` = openssl.evp.EVP_get_digestbyname(name) + if (`type` == null) + F.raiseError(new GeneralSecurityException("EVP_get_digestbyname")) + else { + val md = stackalloc[CUnsignedChar](openssl.evp.EVP_MAX_MD_SIZE) + val size = stackalloc[CUnsignedInt]() + + if (openssl + .evp + .EVP_Digest(data.toPtr, data.size.toULong, md, size, `type`, null) == 1) + F.pure(ByteVector.fromPtr(md.asInstanceOf[Ptr[Byte]], (!size).toLong)) + else + F.raiseError(new GeneralSecurityException("EVP_DIGEST")) + } + } + + } +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/HmacKeyGenPlatform.scala b/crypto/native/src/main/scala/org/http4s/crypto/HmacKeyGenPlatform.scala new file mode 100644 index 0000000..4c0ce37 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/HmacKeyGenPlatform.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto + +import cats.effect.kernel.Sync +import scodec.bits.ByteVector + +import scala.scalanative.unsafe._ + +private[crypto] trait HmacKeyGenCompanionPlatform { + implicit def forSync[F[_]](implicit F: Sync[F]): HmacKeyGen[F] = + new UnsealedHmacKeyGen[F] { + def generateKey[A <: HmacAlgorithm](algorithm: A): F[SecretKey[A]] = + F.delay { + val len = algorithm.minimumKeyLength + val buf = stackalloc[Byte](len.toLong) + if (openssl.rand.RAND_bytes(buf, len) != 1) + throw new GeneralSecurityException("RAND_bytes") + SecretKeySpec(ByteVector.fromPtr(buf, len.toLong), algorithm) + } + } +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/HmacPlatform.scala b/crypto/native/src/main/scala/org/http4s/crypto/HmacPlatform.scala new file mode 100644 index 0000000..c8df420 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/HmacPlatform.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto + +import cats.ApplicativeThrow +import scodec.bits.ByteVector + +import scala.scalanative.unsafe._ +import scala.scalanative.unsigned._ + +private[crypto] trait HmacPlatform[F[_]] + +private[crypto] trait HmacCompanionPlatform { + implicit def forApplicativeThrow[F[_]](implicit F: ApplicativeThrow[F]): Hmac[F] = + new UnsealedHmac[F] { + + def digest(key: SecretKey[HmacAlgorithm], data: ByteVector): F[ByteVector] = + Zone { implicit z => + import HmacAlgorithm._ + + val SecretKeySpec(keyBytes, algorithm) = key + + val name = algorithm match { + case SHA1 => c"SHA1" + case SHA256 => c"SHA256" + case SHA512 => c"SHA512" + } + + val evpMd = openssl.evp.EVP_get_digestbyname(name) + if (evpMd == null) + F.raiseError(new GeneralSecurityException("EVP_get_digestbyname")) + else { + val md = stackalloc[CUnsignedChar](openssl.evp.EVP_MAX_MD_SIZE) + val mdLen = stackalloc[CUnsignedInt]() + + if (openssl + .hmac + .HMAC( + evpMd, + keyBytes.toPtr, + keyBytes.size.toInt, + data.toPtr.asInstanceOf[Ptr[CUnsignedChar]], + data.size.toULong, + md, + mdLen) != null) + F.pure(ByteVector.fromPtr(md.asInstanceOf[Ptr[Byte]], (!mdLen).toLong)) + else + F.raiseError(new GeneralSecurityException("HMAC")) + } + } + + def importKey[A <: HmacAlgorithm](key: ByteVector, algorithm: A): F[SecretKey[A]] = + F.pure(SecretKeySpec(key, algorithm)) + } +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/KeyPlatform.scala b/crypto/native/src/main/scala/org/http4s/crypto/KeyPlatform.scala new file mode 100644 index 0000000..177f965 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/KeyPlatform.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto + +private[crypto] trait KeyPlatform +private[crypto] trait PublicKeyPlatform +private[crypto] trait PrivateKeyPlatform +private[crypto] trait SecretKeyPlatform +private[crypto] trait SecretKeySpecPlatform[+A <: Algorithm] diff --git a/crypto/native/src/main/scala/org/http4s/crypto/openssl/evp.scala b/crypto/native/src/main/scala/org/http4s/crypto/openssl/evp.scala new file mode 100644 index 0000000..396e7e5 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/openssl/evp.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto.openssl + +import scala.annotation.nowarn +import scala.scalanative.unsafe._ + +@link("crypto") +@extern +@nowarn +private[crypto] object evp { + + final val EVP_MAX_MD_SIZE = 64 + + def EVP_Digest( + data: Ptr[Byte], + count: CSize, + md: Ptr[CUnsignedChar], + size: Ptr[CUnsignedInt], + `type`: Ptr[Byte], + impl: Ptr[Byte] + ): CInt = extern + + def EVP_get_digestbyname(name: Ptr[CChar]): Ptr[Byte] = extern + +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/openssl/hmac.scala b/crypto/native/src/main/scala/org/http4s/crypto/openssl/hmac.scala new file mode 100644 index 0000000..003ca80 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/openssl/hmac.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto.openssl + +import scala.annotation.nowarn +import scala.scalanative.unsafe._ + +@link("crypto") +@extern +@nowarn +private[crypto] object hmac { + + def HMAC( + evp_md: Ptr[Byte], + key: Ptr[Byte], + key_len: Int, + d: Ptr[CUnsignedChar], + n: CSize, + md: Ptr[CUnsignedChar], + md_len: Ptr[CUnsignedInt] + ): Ptr[CUnsignedChar] = extern + +} diff --git a/crypto/native/src/main/scala/org/http4s/crypto/openssl/rand.scala b/crypto/native/src/main/scala/org/http4s/crypto/openssl/rand.scala new file mode 100644 index 0000000..b8ca7a3 --- /dev/null +++ b/crypto/native/src/main/scala/org/http4s/crypto/openssl/rand.scala @@ -0,0 +1,32 @@ +/* + * Copyright 2021 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.crypto.openssl + +import scala.annotation.nowarn +import scala.scalanative.unsafe._ + +@link("crypto") +@extern +@nowarn +private[crypto] object rand { + + def RAND_bytes( + buf: Ptr[CChar], + num: CInt + ): CInt = extern + +} diff --git a/crypto/shared/src/test/scala/org/http4s/crypto/HashSuite.scala b/crypto/shared/src/test/scala/org/http4s/crypto/HashSuite.scala index 878d975..357ac98 100644 --- a/crypto/shared/src/test/scala/org/http4s/crypto/HashSuite.scala +++ b/crypto/shared/src/test/scala/org/http4s/crypto/HashSuite.scala @@ -44,7 +44,7 @@ final class HashSuite extends CatsEffectSuite { } def tests[F[_]: Hash: Functor](implicit ct: ClassTag[F[Nothing]]): Unit = { - if (Set("JVM", "NodeJS").contains(BuildInfo.runtime)) + if (Set("JVM", "NodeJS", "Native").contains(BuildInfo.runtime)) testHash[F](MD5, "9e107d9d372bb6826bd81d3542a419d6") testHash[F](SHA1, "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12") testHash[F](SHA256, "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592") @@ -53,7 +53,7 @@ final class HashSuite extends CatsEffectSuite { "07e547d9586f6a73f73fbac0435ed76951218fb7d0c8d788a309d785436bbb642e93a252a954f23912547d1e8a3b5ed6e1bfd7097821233fa0538f3db854fee6") } - if (Set("JVM", "NodeJS").contains(BuildInfo.runtime)) + if (Set("JVM", "NodeJS", "Native").contains(BuildInfo.runtime)) tests[SyncIO] tests[IO] diff --git a/crypto/shared/src/test/scala/org/http4s/crypto/HmacSuite.scala b/crypto/shared/src/test/scala/org/http4s/crypto/HmacSuite.scala index ae642c8..f6968ff 100644 --- a/crypto/shared/src/test/scala/org/http4s/crypto/HmacSuite.scala +++ b/crypto/shared/src/test/scala/org/http4s/crypto/HmacSuite.scala @@ -62,12 +62,12 @@ final class HmacSuite extends CatsEffectSuite { } } - if (Set("JVM", "NodeJS").contains(BuildInfo.runtime)) + if (Set("JVM", "NodeJS", "Native").contains(BuildInfo.runtime)) tests[SyncIO] tests[IO] - if (Set("JVM", "NodeJS").contains(BuildInfo.runtime)) + if (Set("JVM", "Native", "NodeJS").contains(BuildInfo.runtime)) List(SHA1, SHA256, SHA512).foreach(testGenerateKey[SyncIO]) List(SHA1, SHA256, SHA512).foreach(testGenerateKey[IO]) diff --git a/project/plugins.sbt b/project/plugins.sbt index 1e65d8c..7c7f742 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -3,3 +3,5 @@ libraryDependencies += "org.scala-js" %% "scalajs-env-selenium" % "1.1.1" addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.14.4") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.1") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.4.7") +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.2.0")