diff --git a/build.sbt b/build.sbt index 07da615..fed8107 100644 --- a/build.sbt +++ b/build.sbt @@ -72,6 +72,8 @@ lazy val io = project lazy val effect = project .in(file("modules/effect")) + .dependsOn(core) + .dependsOn(io % "test->compile") .settings(sharedSettings) .settings(testSettings) .settings(name := s"${gsProjectName.value}-effect-v${semVerMajor.value}") diff --git a/modules/effect/src/main/scala/src/gs/std/v0/effect/pipes/SHA256Pipe.scala b/modules/effect/src/main/scala/src/gs/std/v0/effect/pipes/SHA256Pipe.scala new file mode 100644 index 0000000..62636a3 --- /dev/null +++ b/modules/effect/src/main/scala/src/gs/std/v0/effect/pipes/SHA256Pipe.scala @@ -0,0 +1,46 @@ +package gs.std.v0.effect.pipes + +import cats.Applicative +import fs2.Chunk +import fs2.Pipe +import fs2.Pull +import fs2.Stream +import gs.std.v0.core.SHA256 +import java.security.MessageDigest + +class SHA256Pipe[F[_]: Applicative] extends Pipe[F, Byte, SHA256]: + + private def newDigest(): MessageDigest = + MessageDigest.getInstance(SHA256.Algorithm) + + private def append( + digest: MessageDigest, + data: Chunk[Byte] + ): F[Unit] = + Applicative[F].pure(digest.update(data.toArray)) + + def pull( + s: Stream[F, Byte], + digest: MessageDigest + ): Pull[F, SHA256, Unit] = + s.pull.uncons.flatMap { + case Some((head, tail)) => + // There is another chunk of data. + // Update the digest and then handle the next chunk. + Pull.eval(append(digest, head)) >> pull(tail, digest) + case None => + // The stream is empty. Emit a single chunk that contains the calculated + // hash. + Pull.output(Chunk(SHA256.fromBytes(digest.digest()))) >> Pull.done + } + + override def apply(v1: Stream[F, Byte]): Stream[F, SHA256] = + pull(v1, newDigest()).stream + +end SHA256Pipe + +object SHA256Pipe: + + def apply[F[_]: Applicative] = new SHA256Pipe[F] + +end SHA256Pipe diff --git a/modules/effect/src/test/resources/sha256-input b/modules/effect/src/test/resources/sha256-input new file mode 100644 index 0000000..9fd0447 Binary files /dev/null and b/modules/effect/src/test/resources/sha256-input differ diff --git a/modules/effect/src/test/scala/src/gs/std/v0/effect/pipes/SHA256PipeTests.scala b/modules/effect/src/test/scala/src/gs/std/v0/effect/pipes/SHA256PipeTests.scala new file mode 100644 index 0000000..19bd2a9 --- /dev/null +++ b/modules/effect/src/test/scala/src/gs/std/v0/effect/pipes/SHA256PipeTests.scala @@ -0,0 +1,91 @@ +package gs.std.v0.effect.pipes + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import fs2.Stream +import gs.std.v0.core.SHA256 +import java.security.MessageDigest +import munit.* + +class SHA256PipeTests extends FunSuite: + + import SHA256PipeTests.Data + + given runtime: IORuntime = IORuntime.global + + def io( + name: String + )( + body: => IO[Unit] + )( + using + loc: Location + ): Unit = + test(name)(body.unsafeRunSync())( + using loc + ) + + def getDigest(): MessageDigest = MessageDigest.getInstance(SHA256.Algorithm) + + def shaDefault(data: String): SHA256 = + SHA256.calculate(data) + + def shaStream(data: String): IO[SHA256] = + Stream + .evals(IO(data.getBytes().toList)) + .through(SHA256Pipe.apply[IO]) + .compile + .toList + .map { + case sha :: Nil => sha + case Nil => fail("Received an empty SHA-256.") + case _ => fail("Received multiple SHA-256.") + } + + def readResource(resourceName: String): Stream[IO, Byte] = + fs2.io.readInputStream( + fis = IO(getClass().getClassLoader().getResourceAsStream(resourceName)), + chunkSize = 1024, + closeAfterUse = true + ) + + io("should calculate the SHA-256 for some fixed (pre-calculated) value") { + val data = "some-fixed-value" + val expectedHex = + "d5dae4f21bc27a568842bc9b467e69bd823d54d1c9e89d5ea662493068d5100d" + val basic = shaDefault(data) + + shaStream(data).map { streamed => + assertEquals(basic.hex().data, expectedHex) + assertEquals(streamed.hex().data, expectedHex) + assert(streamed.isSame(basic)) + } + } + + io("should calculate the SHA-256 for a 1M file") { + readResource(Data.File1Path) + .through(SHA256Pipe[IO]) + .compile + .toList + .map { + case sha :: Nil => sha + case Nil => fail("Received an empty SHA-256.") + case _ => fail("Received multiple SHA-256.") + } + .map(sha => assertEquals(sha.hex().data, Data.File1Sha)) + } + +end SHA256PipeTests + +object SHA256PipeTests: + + object Data: + + val File1Path: String = "sha256-input" + + val File1Sha: String = + "42d96cff2300306954c166ab75862d4e5fd7ce977bb34f7e8ee785f62244d7a0" + + end Data + +end SHA256PipeTests diff --git a/src/main/scala/gs/std/v0/SHA256.scala b/src/main/scala/gs/std/v0/SHA256.scala index 89fb61f..90f7198 100644 --- a/src/main/scala/gs/std/v0/SHA256.scala +++ b/src/main/scala/gs/std/v0/SHA256.scala @@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets import java.security.MessageDigest /** Opaque type representing a SHA-256 hash. + * + * The actual underlying type is an array of bytes. */ opaque type SHA256 = Array[Byte]