From 0786055b084f57756576ef71b8f2fa5998cd01a8 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Wed, 26 Nov 2025 16:50:02 +0100 Subject: [PATCH 01/13] WIP: ring settings and plug board not implemented Add Enigma cipher module: - Introduced `cipher-enigma` module with support for Enigma machine simulation. - Implemented key components: `Rotor`, `RotorState`, `Rotors`, `Reflector`, and `Enigma` core logic. - Added extensive unit tests to validate rotor behavior, reflector mappings, carry logic, and encryption workflow. - Updated `build.sbt` to include `cipher-enigma` module and dependencies. --- .github/workflows/ci.yml | 2 +- build.sbt | 7 + .../scala/cryptic/cipher/enigma/Enigma.scala | 43 +++++ .../scala/cryptic/cipher/enigma/Glyph.scala | 58 +++++++ .../cryptic/cipher/enigma/Reflector.scala | 21 +++ .../scala/cryptic/cipher/enigma/Rotor.scala | 128 +++++++++++++++ .../cryptic/cipher/enigma/EnigmaSpec.scala | 38 +++++ .../cryptic/cipher/enigma/GlyphSpec.scala | 83 ++++++++++ .../cryptic/cipher/enigma/ReflectorSpec.scala | 20 +++ .../cryptic/cipher/enigma/RotorSpec.scala | 52 ++++++ .../cipher/enigma/RotorStateSpec.scala | 81 ++++++++++ .../cryptic/cipher/enigma/RotorsSpec.scala | 153 ++++++++++++++++++ 12 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ed6f6c..5a90ea3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: sbt '++ ${{ matrix.scala }}' test - name: Compress target directories - run: tar cf targets.tar codec-fst/target codec-upickle/target target cipher-javax/target cipher-test/target core/target codec-chill/target cipher-bouncycastle/target project/target + run: tar cf targets.tar cipher-enigma/target codec-fst/target codec-upickle/target target cipher-javax/target cipher-test/target core/target codec-chill/target cipher-bouncycastle/target project/target - name: Upload target directories uses: actions/upload-artifact@v4 diff --git a/build.sbt b/build.sbt index 2601dd2..ee03048 100644 --- a/build.sbt +++ b/build.sbt @@ -182,6 +182,12 @@ lazy val `cipher-test` = (project in file("cipher-test")) `cipher-bouncycastle` ) +lazy val `cipher-enigma` = (project in file("cipher-enigma")) + .settings( + cipherCommonSettings("cipher-enigma") + ) + .dependsOn(core) + lazy val cryptic = (project in file(".")) .enablePlugins(ParadoxPlugin) .settings( @@ -200,5 +206,6 @@ lazy val cryptic = (project in file(".")) `codec-upickle`, `cipher-bouncycastle`, `cipher-javax`, + `cipher-enigma`, `cipher-test` ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala new file mode 100644 index 0000000..d5d50c7 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -0,0 +1,43 @@ +package cryptic +package cipher +package enigma + +import scala.util.{Success, Try} + +case class Settings(rotors: Rotors, reflector: Reflector) + +object Settings: + def apply(start: Char): Settings = Settings( + Rotors( + RotorState(Rotor("I"), Glyph(0), start.glyph), + RotorState(Rotor("II"), Glyph(0), Glyph(0)), + RotorState(Rotor("III"), Glyph(0), Glyph(0)) + ), + Reflector.B + ) + +object Enigma: + /** Version marker for future binary compatibility checks. */ + val version: Version = FixedVersion(0, 0, 0, 1) + + object default: + // Expose common givens and types when using this cipher's default package + export cryptic.default.{given, *} + export Enigma.{given, *} + + given encrypt(using settings: Settings): Encrypt[Try] = + (plaintext: PlainText) => + var rotors = settings.rotors + val enc = plaintext.bytes + .map(_.toChar) + .filter(_.isGlyph) + .map: c => + val g = c.glyph + // Rotate all rotors applying ripple-carry rules + rotors = rotors.rotate + // Forward through rotors, reflect, then backward out + val forward = rotors.in(g) + val reflected = settings.reflector.reflect(forward) + val output = rotors.out(reflected) + output.char.toByte + Success(CipherText(enc)) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala new file mode 100644 index 0000000..bc4de87 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -0,0 +1,58 @@ +package cryptic +package cipher +package enigma + +import scala.util.{Failure, Success, Try} + +opaque type Glyph = Int + +object Glyph: + val mod = 26 + val base = 65 + val one: Glyph = Glyph(1) + def apply(i: Int): Glyph = (i + mod) % mod + def apply(c: Char): Glyph = apply(c.intValue - base) + + /** Safely construct a Glyph from a Char. + * - Accepts A–Z or a–z; lower-case is converted to upper-case first. + * - Returns Failure(IllegalArgumentException) for any non A–Z/a–z + * character. + */ + def unsafe(c: Char): Try[Glyph] = + val upper = if c >= 'a' && c <= 'z' then c.toUpper else c + if upper >= 'A' && upper <= 'Z' then Success(apply(upper)) + else Failure(IllegalArgumentException(s"Invalid character for Glyph: '$c'")) + + /** Returns true if the provided character can be converted to a Glyph according + * to the same rules as `unsafe` (i.e., letters A–Z/a–z only). + */ + def isValid(c: Char): Boolean = Glyph.unsafe(c).isSuccess + +extension (g: Glyph) + def char: Char = (g + Glyph.base).toChar + + /** Underlying 0-25 index value of this Glyph */ + def int: Int = g + def +(term: Glyph): Glyph = Glyph(g + term) + def -(term: Glyph): Glyph = Glyph(g - term) + def ++ : Glyph = Glyph(g + 1) + +extension (c: Char) + def glyph: Glyph = Glyph(c) + def isGlyph: Boolean = Glyph.isValid(c) + +extension (s: String) + /** Converts the characters of a string into an immutable array of Glyphs. + * Uses `Glyph.unsafe` for each character and ignores any failures, meaning + * only the safe alphabetic characters (A–Z/a–z) are included. Lower-case + * letters are treated as their upper-case equivalents by `unsafe`. + */ + def glyph: IArray[Glyph] = + val kept: Array[Glyph] = + s.toCharArray.flatMap(c => Glyph.unsafe(c).toOption) + IArray.from(kept) + +extension (ga: IArray[Glyph]) + /** Convert an immutable array of Glyphs to a String + */ + def string: String = ga.map(_.char).mkString diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala new file mode 100644 index 0000000..6fc385e --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala @@ -0,0 +1,21 @@ +package cryptic +package cipher +package enigma + +import scala.annotation.targetName + +/** Enigma reflectors A, B, C. + * + * Wiring is expressed in the domain type `Glyph` and the primary API operates + * on `Glyph`, with convenience overload for `Char`. + */ +enum Reflector(wiring: IArray[Glyph]): + /** Primary reflect method operating on Glyph domain values */ + def reflect(g: Glyph): Glyph = wiring(g.int) + + /** Convenience overload reflecting a Char */ + def reflect(c: Char): Char = reflect(c.glyph).char + + case A extends Reflector("EJMZALYXVBWFCRQUONTSPIKHGD".glyph) + case B extends Reflector("YRUHQSLDPXNGOKMIEBFZCWVJAT".glyph) + case C extends Reflector("FVPJIAOYEDRZXWGCTKUQSBNMHL".glyph) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala new file mode 100644 index 0000000..6fc509a --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala @@ -0,0 +1,128 @@ +package cryptic +package cipher +package enigma + +import Glyph.* + +case class Rotor(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): + def carry(pos: Glyph): Boolean = notches.contains(pos) + def in(g: Glyph): Glyph = wiring(g.int) + def out(g: Glyph): Glyph = Glyph(wiring.indexOf(g)) + override def toString: String = name + +object Rotor: + // Predefined rotor singletons (kept private) to preserve reference and structural equality + // while "inlining" access through the name-based apply. + private lazy val predefined: Map[String, Rotor] = + Array[ + (String, String, String) + ]( + ("I", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R"), + ("II", "AJDKSIRUXBLHWTMCQGZNPYFVOE", "F"), + ("III", "BDFHJLCPRTXVZNYEIWGAKMUSQO", "W"), + ("IV", "ESOVPZJAYQUIRHXLNFTGKDCMWB", "K"), + ("V", "VZBRGITYUPSDNHLXAWMJQOFECK", "A"), + ("VI", "JPGVOUMFYQBENHZRDKASXLICTW", "AN") + ).map: (name, wiring, notch) => + name -> Rotor(name, wiring, notch) + .toMap + + /** Construct a Rotor from notch and wiring strings. Non‑alphabetic characters + * are ignored (per `String.glyph`). + */ + def apply(name: String, wiring: String, notch: String): Rotor = + val n = notch.glyph + val w = wiring.glyph + require( + n.nonEmpty, + "Rotor notch must be non-empty (at least one valid A–Z char)" + ) + require( + n.distinct.length == n.length, + "Rotor notch characters must be non-repeating" + ) + require( + w.length == Glyph.mod, + s"Rotor wiring must have length ${Glyph.mod}" + ) + Rotor(name, w, n) + + /** Lookup a predefined rotor by its conventional name ("I" .. "VI"). */ + def apply(name: String): Rotor = + predefined.getOrElse( + name, + throw IllegalArgumentException(s"Unknown rotor name: '$name'") + ) + +case class RotorState(rotor: Rotor, ring: Glyph, pos: Glyph): + def rotate: RotorState = copy(pos = pos.++) + def carry: Boolean = rotor.carry(pos + ring) + + def in(g: Glyph): Glyph = rotor.in(g + pos) - pos + def in(c: Char): Char = in(c.glyph).char + + def out(g: Glyph): Glyph = rotor.out(g + pos) - pos + def out(c: Char): Char = out(c.glyph).char + + override def toString: String = + s"RotorState($rotor, ${"%02d".format(ring.int)}, ${pos.char})" + +object RotorState: + /** Convenience constructor for tests and callers that prefer simple types. + * Builds a `RotorState` from a rotor name, numeric ring setting, and a + * character position. + */ + def apply(rotorName: String, ring: Int, pos: Char): RotorState = + RotorState(Rotor(rotorName), Glyph(ring), pos.glyph) + +case class Rotors(states: IArray[RotorState]): + require( + states.nonEmpty, + "Rotors must contain at least one rotor state (right-most at index 0)" + ) + require( + states.map(_.rotor.name).distinct.length == states.length, + "Rotors state must not contain duplicate rotors" + ) + + /** Pass a glyph through the rotor stack from right-most to left-most, + * chaining each `RotorState.in` just like `Enigma.encrypt` does. + */ + def in(g: Glyph): Glyph = states.foldLeft(g)((acc, s) => s.in(acc)) + + /** Convenience overload for chars. */ + def in(c: Char): Char = in(c.glyph).char + + /** Pass a glyph back through the rotor stack from left-most to right-most, + * chaining each `RotorState.out` in reverse order of `in`, matching + * `Enigma.encrypt`'s return path after the reflector. + */ + def out(g: Glyph): Glyph = states.foldRight(g)((s, acc) => s.out(acc)) + + /** Convenience overload for chars. */ + def out(c: Char): Char = out(c.glyph).char + + /** Rotate the rotors applying Enigma carry rules for an arbitrary number of + * rotors. States are ordered from right to left (index 0 = right-most, + * highest speed): + * - The right-most rotor (index 0) always rotates. + * - A carry from rotor i after its rotation causes rotor i+1 to rotate, + * and so on. + */ + def rotate: Rotors = + val (rotated, _) = states.foldLeft((Seq[RotorState](), true)): + case ((seq, true), state) => + val next = state.rotate + (seq :+ next, next.carry) + case ((seq, false), state) => + (seq :+ state, state.carry) + Rotors(IArray.from(rotated)) + +object Rotors: + /** Convenience constructor allowing varargs of RotorState with a minimum of one. + * Right-most rotor should be provided first (index 0), matching the IArray + * ordering used by the primary case class. + */ + def apply(head: RotorState, tail: RotorState*): Rotors = + val arr: IArray[RotorState] = IArray.from(head +: tail.toSeq) + Rotors(arr) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala new file mode 100644 index 0000000..49d44fb --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -0,0 +1,38 @@ +package cryptic +package cipher +package enigma + +import cryptic.cipher.enigma.Reflector.A +import org.scalatest.flatspec.{AnyFlatSpec, AsyncFlatSpec} +import org.scalatest.matchers.should.Matchers +import org.scalatest.TryValues + +import scala.util.{Success, Try} + +class EnigmaSpec extends AnyFlatSpec with Matchers with TryValues: + behavior of "Enigma" + + import Enigma.default.{given, *} + import Functor.tryFunctor + + "Enigma" should "encrypt MARTIN to WFSEXB" in: + given settings: Settings = Settings('Z') + val enc: Encrypted[Try, String] = + "MARTIN".encrypted + enc.bytes.success.value shouldBe "WFSEXB".bytes + + it should "encrypt A to T when Z" in: + given settings:Settings = Settings('Z') + "A".encrypted.bytes.success.value shouldBe "N".bytes + + it should "encrypt A to T when A" in: + given settings:Settings = Settings('A') + "A".encrypted.bytes.success.value shouldBe "F".bytes + + it should "encrypt A to T when B" in: + given settings:Settings = Settings('B') + "A".encrypted.bytes.success.value shouldBe "T".bytes + + it should "encrypt A to T when C" in: + given settings:Settings = Settings('C') + "A".encrypted.bytes.success.value shouldBe "Z".bytes diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala new file mode 100644 index 0000000..bce340e --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala @@ -0,0 +1,83 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class GlyphSpec extends AnyFlatSpec with Matchers: + behavior of "Glyph" + + import Glyph.* + + "Glyph.apply(Int)" should "wrap values mod 26" in: + Glyph(0).char shouldBe 'A' + Glyph(25).char shouldBe 'Z' + Glyph(26).char shouldBe 'A' + Glyph(27).char shouldBe 'B' + Glyph(-1).char shouldBe 'Z' + + it should "construct from Char" in: + 'A'.glyph.char shouldBe 'A' + 'Z'.glyph.char shouldBe 'Z' + 'B'.glyph.char shouldBe 'B' + + it should "add and subtract with wrap-around" in: + ('A'.glyph + Glyph(1)).char shouldBe 'B' + ('Z'.glyph + Glyph(1)).char shouldBe 'A' + ('B'.glyph - Glyph(1)).char shouldBe 'A' + ('A'.glyph - Glyph(1)).char shouldBe 'Z' + + it should "increment with ++ and wrap-around" in: + 'A'.glyph.++.char shouldBe 'B' + 'Z'.glyph.++.char shouldBe 'A' + // Chaining ++ should advance twice + 'Y'.glyph.++.++.char shouldBe 'A' + // ++ should be equivalent to adding one + 'C'.glyph.++.char shouldBe ('C'.glyph + one).char + + "String.glyph" should "convert a String to Glyphs and back" in: + val s = "ABZ" + val g = s.glyph + g.string shouldBe s + + it should "upper-case lower-case input before mapping to Glyphs" in: + "abz".glyph.string shouldBe "ABZ" + "aBz".glyph.string shouldBe "ABZ" + "martin".glyph.string shouldBe "MARTIN" + + it should "ignore non alphabetic characters using unsafe and keep only letters" in: + "A-B Z1".glyph.string shouldBe "ABZ" + "--__ 123".glyph.string shouldBe "" + "a-b_z".glyph.string shouldBe "ABZ" + + "IArray[Glyph].string" should "handle empty and multi-letter strings" in: + IArray.empty[Glyph].string shouldBe "" + "MARTIN".glyph.string shouldBe "MARTIN" + + "Glyph.unsafe(Char)" should "accept uppercase letters" in: + Glyph.unsafe('A').get.char shouldBe 'A' + Glyph.unsafe('Z').get.char shouldBe 'Z' + + it should "convert lowercase letters to uppercase and succeed" in: + Glyph.unsafe('a').get.char shouldBe 'A' + Glyph.unsafe('m').get.char shouldBe 'M' + Glyph.unsafe('z').get.char shouldBe 'Z' + + it should "fail for non alphabetic characters with IllegalArgumentException" in: + val invalids = List('1', ' ', '-', '[', '`', 'å') + invalids.foreach { c => + val t = Glyph.unsafe(c) + t.isFailure shouldBe true + t.failed.get shouldBe a [IllegalArgumentException] + } + + "Glyph.isValid(Char)" should "return true for letters A–Z and a–z" in: + Glyph.isValid('A') shouldBe true + Glyph.isValid('Z') shouldBe true + Glyph.isValid('a') shouldBe true + Glyph.isValid('z') shouldBe true + + it should "return false for non alphabetic characters" in: + val invalids = List('1', ' ', '-', '[', '`', 'å') + invalids.foreach { c => + Glyph.isValid(c) shouldBe false + } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala new file mode 100644 index 0000000..ebf7a62 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala @@ -0,0 +1,20 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ReflectorSpec extends AnyFlatSpec with Matchers: + behavior of "Reflector" + + private val a: Reflector = Reflector.A + private val b: Reflector = Reflector.B + private val c: Reflector = Reflector.C + "Reflectors" should "be symmetric" in: + ('A' to 'Z').foreach: i => + a.reflect(a.reflect(i)) shouldBe i + b.reflect(b.reflect(i)) shouldBe i + c.reflect(c.reflect(i)) shouldBe i + + "Reflector B" should "map" in: + ('A' to 'Z').foreach: c => + b.reflect(c) should not be c diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala new file mode 100644 index 0000000..a79b7fe --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala @@ -0,0 +1,52 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorSpec extends AnyFlatSpec with Matchers: + behavior of "Rotor.apply" + + it should "construct a rotor from valid notch and wiring strings" in: + val r = Rotor("Custom", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R") + r.carry('R'.glyph) shouldBe true + + it should "reject an empty (or entirely invalid) notch" in: + intercept[IllegalArgumentException] { + Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "") + } + intercept[IllegalArgumentException] { + Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "--- __") + } + + it should "reject a notch with repeating characters" in: + intercept[IllegalArgumentException] { + Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "AA") + } + + it should "require wiring length to equal Glyph.mod (26)" in: + // Too short (25) + intercept[IllegalArgumentException] { + Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXY", "A") + } + // Too long (27) + intercept[IllegalArgumentException] { + Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZA", "A") + } + + behavior of "Rotor.apply(name:String)" + + it should "return predefined rotors by name I..VI" in: + Rotor("I").toString shouldBe "I" + Rotor("II").toString shouldBe "II" + Rotor("III").toString shouldBe "III" + Rotor("IV").toString shouldBe "IV" + Rotor("V").toString shouldBe "V" + Rotor("VI").toString shouldBe "VI" + + it should "throw for unknown rotor names" in: + intercept[IllegalArgumentException] { + Rotor("VII") + } + intercept[IllegalArgumentException] { + Rotor("") + } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala new file mode 100644 index 0000000..7596049 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala @@ -0,0 +1,81 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorStateSpec extends AnyFlatSpec with Matchers: + behavior of "RotorState" + + private val rotorStateA = RotorState(Rotor("I"), Glyph(0), 'A'.glyph) + private val rotorStateB = RotorState(Rotor("I"), Glyph(0), 'B'.glyph) + private val rotorStateC = RotorState(Rotor("I"), Glyph(0), 'C'.glyph) + private val rotorStateQ = RotorState(Rotor("I"), Glyph(0), 'Q'.glyph) + + "Rotor I, ring 0, pos A" should "have a nice toString" in: + rotorStateA.toString shouldBe "RotorState(I, 00, A)" + + it should "map inputs" in: + rotorStateA.in('A') shouldBe 'E' + rotorStateA.in('E') shouldBe 'L' + rotorStateA.in('B') shouldBe 'K' + rotorStateA.in('J') shouldBe 'Z' + + it should "map outputs" in: + rotorStateA.out('E') shouldBe 'A' + rotorStateA.out('L') shouldBe 'E' + rotorStateA.out('K') shouldBe 'B' + rotorStateA.out('Z') shouldBe 'J' + + it should "have carry = false" in: + rotorStateA.carry shouldBe false + + it should "rotate" in: + rotorStateA.rotate shouldBe rotorStateB + rotorStateB.rotate shouldBe rotorStateC + + "Rotor I pos B" should "map in" in: + rotorStateB.in('A') shouldBe 'J' + rotorStateB.in('B') shouldBe 'L' + rotorStateB.in('C') shouldBe 'E' + rotorStateB.in('J') shouldBe 'M' + rotorStateB.in('Z') shouldBe 'D' + rotorStateB.in('V') shouldBe 'A' + + it should "map out" in: + rotorStateB.out('J') shouldBe 'A' + rotorStateB.out('L') shouldBe 'B' + rotorStateB.out('E') shouldBe 'C' + rotorStateB.out('M') shouldBe 'J' + rotorStateB.out('D') shouldBe 'Z' + rotorStateB.out('A') shouldBe 'V' + + "Rotor I pos C" should "map in" in: + rotorStateC.in('A') shouldBe 'K' + rotorStateC.in('B') shouldBe 'D' + rotorStateC.in('R') shouldBe 'N' + rotorStateC.in('S') shouldBe 'Y' + rotorStateC.in('Y') shouldBe 'C' + rotorStateC.in('Z') shouldBe 'I' + + it should "map out" in: + rotorStateC.out('K') shouldBe 'A' + rotorStateC.out('D') shouldBe 'B' + rotorStateC.out('N') shouldBe 'R' + rotorStateC.out('I') shouldBe 'Z' + rotorStateC.out('Y') shouldBe 'S' + + "Rotor I, ring 0, pos Q" should "map input B to K" in: + rotorStateQ.in('B') shouldBe 'E' + + "Rotor I" should "rotate and carry" in: + val nextState = rotorStateQ.rotate + nextState shouldBe RotorState(Rotor("I"), Glyph(0), 'R'.glyph) + nextState.carry shouldBe true + + "Rotor VI" should "have double notch" in: + RotorState(Rotor("VI"), Glyph(0), 'A'.glyph).carry shouldBe true + RotorState(Rotor("VI"), Glyph(0), 'N'.glyph).carry shouldBe true + RotorState(Rotor("VI"), Glyph(0), 'Z'.glyph).carry shouldBe false + RotorState(Rotor("VI"), Glyph(0), 'B'.glyph).carry shouldBe false + RotorState(Rotor("VI"), Glyph(0), 'M'.glyph).carry shouldBe false + RotorState(Rotor("VI"), Glyph(0), 'O'.glyph).carry shouldBe false diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala new file mode 100644 index 0000000..4c31de8 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala @@ -0,0 +1,153 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorsSpec extends AnyFlatSpec with Matchers: + behavior of "Rotors.rotate" + + it should "rotate the right rotor only when no carry occurs (3 rotors)" in: + val right0 = RotorState("I", 0, 'A') // Rotor I: will move to B, no carry (notch = R) + val middle0 = RotorState("II", 0, 'A') // Different rotor to satisfy uniqueness + val left0 = RotorState("III", 0, 'A') + val rotors0 = Rotors(IArray(right0, middle0, left0)) + + val rotors1 = rotors0.rotate + + rotors1.states(0).pos shouldBe 'B'.glyph // right advanced + rotors1.states(1).pos shouldBe 'A'.glyph // middle unchanged + rotors1.states(2).pos shouldBe 'A'.glyph // left unchanged + + it should "rotate middle when right carries after its rotation (3 rotors)" in: + val right0 = RotorState("I", 0, 'Q') // rotate -> R, which carries for Rotor I + val middle0 = RotorState("II", 0, 'A') // will rotate to B (no carry for Rotor II) + val left0 = RotorState("III", 0, 'A') + val rotors0 = Rotors(IArray(right0, middle0, left0)) + + val rotors1 = rotors0.rotate + + rotors1.states(0).pos shouldBe 'R'.glyph // right advanced and at notch + rotors1.states(1).pos shouldBe 'B'.glyph // middle advanced due to carry + rotors1.states(2).pos shouldBe 'A'.glyph // left unchanged (no carry from middle yet) + + it should "rotate left when middle carries after its rotation (double step trigger, 3 rotors)" in: + val right0 = RotorState("I", 0, 'Q') // rotate -> R and carries, causing middle rotate + val middle0 = RotorState("II", 0, 'E') // when rotated (due to right carry) -> F (Rotor II notch) and carries + val left0 = RotorState("III", 0, 'A') + val rotors0 = Rotors(IArray(right0, middle0, left0)) + + val rotors1 = rotors0.rotate + + rotors1.states(0).pos shouldBe 'R'.glyph // right advanced + rotors1.states(1).pos shouldBe 'F'.glyph // middle advanced to notch (Rotor II) + rotors1.states(2).pos shouldBe 'B'.glyph // left advanced due to middle carry + + it should "work with a single rotor (always rotates)" in: + val single0 = Rotors(IArray(RotorState("I", 0, 'A'))) + val single1 = single0.rotate + single1.states.length shouldBe 1 + single1.states(0).pos shouldBe 'B'.glyph + + it should "ripple carry across four rotors" in: + // Set up a chain where first rotation causes cascading carries: + // Right (Rotor I) at Q -> rotates to R and carries (Rotor I notch R) + // Next (Rotor II) at E -> when rotated to F, carries (Rotor II notch F) + // Next (Rotor III) at V -> when rotated to W, carries (Rotor III notch W) + // Leftmost (Rotor IV) at A -> should rotate to B due to carry chain + val r0 = RotorState("I", 0, 'Q') + val r1 = RotorState("II", 0, 'E') + val r2 = RotorState("III", 0, 'V') + val r3 = RotorState("IV", 0, 'A') + val rotors0 = Rotors(IArray(r0, r1, r2, r3)) + val rotors1 = rotors0.rotate + rotors1.states.map(_.pos) shouldBe IArray('R'.glyph, 'F'.glyph, 'W'.glyph, 'B'.glyph) + + behavior of "Rotors constructor" + + it should "require at least one rotor state and accept arbitrary sizes" in: + // empty should fail + intercept[IllegalArgumentException] { + Rotors(IArray.empty[RotorState]) + } + // 1, 2, 3, 4 should all be allowed + noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'))) + noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'))) + noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("III", 0, 'A'))) + noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("III", 0, 'A'), RotorState("IV", 0, 'A'))) + + it should "reject duplicate rotors (by identity/name)" in: + // Duplicate rotor I used twice should fail + intercept[IllegalArgumentException] { + Rotors(IArray(RotorState("I", 0, 'A'), RotorState("I", 0, 'B'))) + } + // Duplicates among more than two should also fail + intercept[IllegalArgumentException] { + Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("I", 0, 'C'))) + } + + behavior of "Rotors.in" + + it should "chain RotorState.in from right-most to left-most (3 rotors)" in: + val r0 = RotorState("I", 0, 'A') + val r1 = RotorState("II", 0, 'A') + val r2 = RotorState("III", 0, 'A') + val rotors = Rotors(IArray(r0, r1, r2)) + + val input = 'A'.glyph + val step1 = r0.in(input) + val step2 = r1.in(step1) + val step3 = r2.in(step2) + + rotors.in(input) shouldBe step3 + + it should "match single rotor behavior and support char overload" in: + val single = Rotors(IArray(RotorState("I", 0, 'A'))) + val g = 'C'.glyph + + single.in(g) shouldBe single.states(0).in(g) + single.in('C') shouldBe single.in(g).char + + behavior of "Rotors companion object apply" + + it should "construct from varargs with at least one state (single)" in: + val single = Rotors(RotorState("I", 0, 'A')) + single.states.length shouldBe 1 + single.states(0).pos shouldBe 'A'.glyph + + it should "construct from varargs with multiple states and preserve order" in: + val r0 = RotorState("I", 0, 'A') + val r1 = RotorState("II", 0, 'B') + val r2 = RotorState("III", 0, 'C') + val rotors = Rotors(r0, r1, r2) + rotors.states.length shouldBe 3 + rotors.states(0) shouldBe r0 + rotors.states(1) shouldBe r1 + rotors.states(2) shouldBe r2 + + it should "reject duplicates when constructed via varargs" in: + intercept[IllegalArgumentException] { + Rotors(RotorState("I", 0, 'A'), RotorState("I", 0, 'B')) + } + + behavior of "Rotors.out" + + it should "chain RotorState.out from left-most to right-most (3 rotors)" in: + val r0 = RotorState("I", 0, 'A') + val r1 = RotorState("II", 0, 'A') + val r2 = RotorState("III", 0, 'A') + val rotors = Rotors(IArray(r0, r1, r2)) + + val input = 'Z'.glyph + // Out path is left-most to right-most: r2, then r1, then r0 + val step1 = r2.out(input) + val step2 = r1.out(step1) + val step3 = r0.out(step2) + + rotors.out(input) shouldBe step3 + + it should "match single rotor behavior and support char overload" in: + val single = Rotors(IArray(RotorState("I", 0, 'A'))) + val g = 'M'.glyph + + single.out(g) shouldBe single.states(0).out(g) + single.out('M') shouldBe single.out(g).char From 63dc0272b9f2a9b86645b005624942d7603a2b50 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 30 Nov 2025 17:45:07 +0100 Subject: [PATCH 02/13] Refactor Rotor and Enigma logic: - Replaced `RotorState` with streamlined `Rotor` model, integrating offset-based transformations. - Introduced `Wheel` abstraction to encapsulate rotor wiring and notch logic, improving modularity. - Added dedicated tests for `Rotor.apply` and `Rotors.apply` settings parsing and validation. - Enhanced encryption tests to validate carry logic, rotor rotations, and backward compatibility. - Minor improvements in `Glyph` extensions and resource loading utilities. --- build.sbt | 42 ++--- .../scala/cryptic/cipher/enigma/Enigma.scala | 74 +++++--- .../scala/cryptic/cipher/enigma/Glyph.scala | 13 +- .../scala/cryptic/cipher/enigma/Rotor.scala | 158 +++++------------- .../scala/cryptic/cipher/enigma/Rotors.scala | 153 +++++++++++++++++ .../scala/cryptic/cipher/enigma/Wheel.scala | 58 +++++++ .../test/resources/lorem-cipher-AAA-AAA.txt | 1 + .../test/resources/lorem-cipher-MAR_ADQ.txt | 1 + .../test/resources/lorem-cipher-ZBQ-AAA.txt | 1 + cipher-enigma/src/test/resources/lorem.txt | 1 + .../cryptic/cipher/enigma/EnigmaSpec.scala | 65 ++++--- .../cryptic/cipher/enigma/GlyphSpec.scala | 28 ++++ .../cipher/enigma/RotorApplySpec.scala | 46 +++++ .../cryptic/cipher/enigma/RotorSpec.scala | 144 ++++++++++------ .../cipher/enigma/RotorStateSpec.scala | 81 --------- .../cipher/enigma/RotorsApplySpec.scala | 56 +++++++ .../cryptic/cipher/enigma/RotorsSpec.scala | 152 +++++++++-------- .../cryptic/cipher/enigma/WheelSpec.scala | 52 ++++++ .../src/main/scala/cryptic/cipher/javax.scala | 2 - core/src/main/scala/cryptic/core.scala | 15 +- .../scala/cryptic/AADParameterizedSpec.scala | 30 ++++ 21 files changed, 781 insertions(+), 392 deletions(-) create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala create mode 100644 cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt create mode 100644 cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt create mode 100644 cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt create mode 100644 cipher-enigma/src/test/resources/lorem.txt create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala delete mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala create mode 100644 core/src/test/scala/cryptic/AADParameterizedSpec.scala diff --git a/build.sbt b/build.sbt index ee03048..217cdb6 100644 --- a/build.sbt +++ b/build.sbt @@ -7,6 +7,7 @@ lazy val chill = ("com.twitter" % "chill" % "0.10.0") lazy val fst = ("de.ruedigermoeller" % "fst" % "3.0.3").withSources() lazy val upickle = ("com.lihaoyi" %% "upickle" % "4.3.2").withSources() lazy val bc = ("org.bouncycastle" % "bcprov-jdk18on" % "1.82").withSources() +lazy val scribe = ("com.outr" %% "scribe" % "3.17.0").withSources() lazy val javaBaseOpens = Seq( "--add-opens=java.base/java.lang=ALL-UNNAMED", @@ -79,11 +80,14 @@ lazy val commonSettings = ) ++ testSettings lazy val testSettings = - Seq(Test / fork := true, Test / javaOptions ++= javaBaseOpens) + Seq( + Test / fork := true, + Test / javaOptions ++= javaBaseOpens, + libraryDependencies += scalaTest % Test + ) lazy val coreSettings = commonSettings ++ Seq( name := "core", - libraryDependencies ++= Seq(scalaTest % Test), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -94,7 +98,7 @@ lazy val coreSettings = commonSettings ++ Seq( lazy val codecChillSettings = commonSettings ++ Seq( name := "codec-chill", - libraryDependencies ++= Seq(scalaTest % Test, chill), + libraryDependencies ++= Seq(chill), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -102,9 +106,9 @@ lazy val codecChillSettings = commonSettings ++ Seq( s"cryptic-${artifact.name.replace("codec-", "")}-${module.revision}.${artifact.extension}" } ) -lazy val codecFstSettings = commonSettings ++ testSettings ++ Seq( +lazy val codecFstSettings = commonSettings ++ Seq( name := "codec-fst", - libraryDependencies ++= Seq(scalaTest % Test, fst), + libraryDependencies += fst, Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -115,7 +119,7 @@ lazy val codecFstSettings = commonSettings ++ testSettings ++ Seq( lazy val codecUpickleSettings = commonSettings ++ Seq( name := "codec-upickle", - libraryDependencies ++= Seq(scalaTest % Test, upickle), + libraryDependencies += upickle, Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -127,7 +131,6 @@ lazy val codecUpickleSettings = commonSettings ++ Seq( lazy val cipherCommonSettings = (projectName: String) => commonSettings ++ Seq( name := projectName, - libraryDependencies ++= Seq(scalaTest % Test), Compile / packageBin / mappings += { file("LICENSE") -> "LICENSE" }, @@ -138,9 +141,8 @@ lazy val cipherCommonSettings = (projectName: String) => ) lazy val cipherTestSettings = - commonSettings ++ testSettings ++ Seq( + commonSettings ++ Seq( name := "cipher-test", - libraryDependencies ++= Seq(scalaTest % Test), publish / skip := true ) @@ -157,20 +159,24 @@ lazy val `codec-upickle` = (project in file("codec-upickle")) .dependsOn(core) lazy val `cipher-javax` = (project in file("cipher-javax")) - .settings( - cipherCommonSettings("cipher-javax") ++ Seq( - libraryDependencies ++= Seq(bc, scalaTest % Test) - ) - ) + .settings(cipherCommonSettings("cipher-javax")) .dependsOn(core) lazy val `cipher-bouncycastle` = (project in file("cipher-bouncycastle")) .settings( cipherCommonSettings("cipher-bouncycastle") ++ Seq( - libraryDependencies ++= Seq(bc, scalaTest % Test) + libraryDependencies += bc ) ) .dependsOn(core, `cipher-javax`) +lazy val `cipher-enigma` = (project in file("cipher-enigma")) + .settings( + cipherCommonSettings("cipher-enigma") ++ Seq( + libraryDependencies += scribe + ) + ) + .dependsOn(core) + lazy val `cipher-test` = (project in file("cipher-test")) .settings(cipherTestSettings) .dependsOn( @@ -182,12 +188,6 @@ lazy val `cipher-test` = (project in file("cipher-test")) `cipher-bouncycastle` ) -lazy val `cipher-enigma` = (project in file("cipher-enigma")) - .settings( - cipherCommonSettings("cipher-enigma") - ) - .dependsOn(core) - lazy val cryptic = (project in file(".")) .enablePlugins(ParadoxPlugin) .settings( diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index d5d50c7..7d2a4c5 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -7,18 +7,37 @@ import scala.util.{Success, Try} case class Settings(rotors: Rotors, reflector: Reflector) object Settings: - def apply(start: Char): Settings = Settings( - Rotors( - RotorState(Rotor("I"), Glyph(0), start.glyph), - RotorState(Rotor("II"), Glyph(0), Glyph(0)), - RotorState(Rotor("III"), Glyph(0), Glyph(0)) - ), - Reflector.B - ) + /** Convenience constructor parsing a full settings string including reflector. + * + * Expected format (whitespace tolerant): + * "names rings positions reflector" + * + * Examples: + * - "III-II-I AAA AAZ B" + * - "VI-II-I ABC DEF C" + * + * Notes: + * - Rotor names are hyphen-separated, left-most to right-most. + * - Ring and position sequences are letters (A–Z or a–z), also left-most to right-most. + * - Reflector is mandatory and must be one of A, B, C (case-insensitive allowed via enum valueOf with uppercasing). + */ + def apply(settings: String): Settings = + // Match four parts: names, rings, positions, reflector + val SettingsRe = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r + + settings match + case SettingsRe(namesPart, ringsPart, posPart, reflPart) => + // Build Rotors from the first three parts using existing parser + val rotors = Rotors(s"$namesPart $ringsPart $posPart") + // Reflector must be a single letter A/B/C; tolerate full word matching enum name as well + val reflector = Reflector.valueOf(reflPart.toUpperCase) + Settings(rotors, reflector) + case _ => + throw IllegalArgumentException( + "Settings.apply requires format \"names rings positions reflector\" (e.g. \"III-II-I AAA AAZ B\")" + ) object Enigma: - /** Version marker for future binary compatibility checks. */ - val version: Version = FixedVersion(0, 0, 0, 1) object default: // Expose common givens and types when using this cipher's default package @@ -26,18 +45,23 @@ object Enigma: export Enigma.{given, *} given encrypt(using settings: Settings): Encrypt[Try] = - (plaintext: PlainText) => - var rotors = settings.rotors - val enc = plaintext.bytes - .map(_.toChar) - .filter(_.isGlyph) - .map: c => - val g = c.glyph - // Rotate all rotors applying ripple-carry rules - rotors = rotors.rotate - // Forward through rotors, reflect, then backward out - val forward = rotors.in(g) - val reflected = settings.reflector.reflect(forward) - val output = rotors.out(reflected) - output.char.toByte - Success(CipherText(enc)) + (plaintext: PlainText) => Success(CipherText(run(plaintext.bytes))) + + given decrypt(using settings: Settings): Decrypt[Try] = + (cipherText: CipherText) => Success(PlainText(run(cipherText.bytes))) + + def run(bytes: IArray[Byte])(using settings: Settings): IArray[Byte] = + var rotors = settings.rotors + bytes + .map(_.toChar) + .filter(_.isGlyph) + .map: c => + val g = c.glyph + // Rotate all rotors applying ripple-carry rules + rotors = rotors.rotate + // Forward through rotors, reflect, then backward out + val (forward, inTrace) = rotors.in(g) + val reflected = settings.reflector.reflect(forward) + val (output, outTrace) = rotors.out(reflected) + scribe.debug(s"encoded ${inTrace.string}-${outTrace.string}") + output.char.toByte diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala index bc4de87..523880a 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -30,12 +30,13 @@ object Glyph: extension (g: Glyph) def char: Char = (g + Glyph.base).toChar - + def string:String = char.toString /** Underlying 0-25 index value of this Glyph */ def int: Int = g def +(term: Glyph): Glyph = Glyph(g + term) def -(term: Glyph): Glyph = Glyph(g - term) def ++ : Glyph = Glyph(g + 1) + def -- : Glyph = Glyph(g - 1) extension (c: Char) def glyph: Glyph = Glyph(c) @@ -56,3 +57,13 @@ extension (ga: IArray[Glyph]) /** Convert an immutable array of Glyphs to a String */ def string: String = ga.map(_.char).mkString + +extension (seq: Seq[Glyph]) + /** + * Constructs a string by concatenating the `char` representation of elements + * in the sequence `seq`. + * + * @return A string composed of the characters obtained from the `char` property + * of each element in the sequence `seq`. + */ + def string: String = seq.map(_.char).mkString diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala index 6fc509a..070bb65 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala @@ -2,127 +2,55 @@ package cryptic package cipher package enigma -import Glyph.* +case class Rotor(wheel: Wheel, ring: Glyph, pos: Glyph): + def rotate: Rotor = copy(pos = pos.++) + val offset: Glyph = pos - ring + def carry: Boolean = wheel.carry(pos) + def previousCarry:Boolean = wheel.carry(pos.--) -case class Rotor(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): - def carry(pos: Glyph): Boolean = notches.contains(pos) - def in(g: Glyph): Glyph = wiring(g.int) - def out(g: Glyph): Glyph = Glyph(wiring.indexOf(g)) - override def toString: String = name - -object Rotor: - // Predefined rotor singletons (kept private) to preserve reference and structural equality - // while "inlining" access through the name-based apply. - private lazy val predefined: Map[String, Rotor] = - Array[ - (String, String, String) - ]( - ("I", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R"), - ("II", "AJDKSIRUXBLHWTMCQGZNPYFVOE", "F"), - ("III", "BDFHJLCPRTXVZNYEIWGAKMUSQO", "W"), - ("IV", "ESOVPZJAYQUIRHXLNFTGKDCMWB", "K"), - ("V", "VZBRGITYUPSDNHLXAWMJQOFECK", "A"), - ("VI", "JPGVOUMFYQBENHZRDKASXLICTW", "AN") - ).map: (name, wiring, notch) => - name -> Rotor(name, wiring, notch) - .toMap - - /** Construct a Rotor from notch and wiring strings. Non‑alphabetic characters - * are ignored (per `String.glyph`). - */ - def apply(name: String, wiring: String, notch: String): Rotor = - val n = notch.glyph - val w = wiring.glyph - require( - n.nonEmpty, - "Rotor notch must be non-empty (at least one valid A–Z char)" - ) - require( - n.distinct.length == n.length, - "Rotor notch characters must be non-repeating" - ) - require( - w.length == Glyph.mod, - s"Rotor wiring must have length ${Glyph.mod}" - ) - Rotor(name, w, n) - - /** Lookup a predefined rotor by its conventional name ("I" .. "VI"). */ - def apply(name: String): Rotor = - predefined.getOrElse( - name, - throw IllegalArgumentException(s"Unknown rotor name: '$name'") - ) - -case class RotorState(rotor: Rotor, ring: Glyph, pos: Glyph): - def rotate: RotorState = copy(pos = pos.++) - def carry: Boolean = rotor.carry(pos + ring) - - def in(g: Glyph): Glyph = rotor.in(g + pos) - pos + def in(g: Glyph): Glyph = wheel.in(g + offset) - offset def in(c: Char): Char = in(c.glyph).char - def out(g: Glyph): Glyph = rotor.out(g + pos) - pos + def out(g: Glyph): Glyph = wheel.out(g + offset) - offset def out(c: Char): Char = out(c.glyph).char - override def toString: String = - s"RotorState($rotor, ${"%02d".format(ring.int)}, ${pos.char})" + override def toString: String = s"Rotor($wheel ${ring.char} ${pos.char})" -object RotorState: +object Rotor: /** Convenience constructor for tests and callers that prefer simple types. - * Builds a `RotorState` from a rotor name, numeric ring setting, and a - * character position. - */ - def apply(rotorName: String, ring: Int, pos: Char): RotorState = - RotorState(Rotor(rotorName), Glyph(ring), pos.glyph) - -case class Rotors(states: IArray[RotorState]): - require( - states.nonEmpty, - "Rotors must contain at least one rotor state (right-most at index 0)" - ) - require( - states.map(_.rotor.name).distinct.length == states.length, - "Rotors state must not contain duplicate rotors" - ) - - /** Pass a glyph through the rotor stack from right-most to left-most, - * chaining each `RotorState.in` just like `Enigma.encrypt` does. - */ - def in(g: Glyph): Glyph = states.foldLeft(g)((acc, s) => s.in(acc)) - - /** Convenience overload for chars. */ - def in(c: Char): Char = in(c.glyph).char - - /** Pass a glyph back through the rotor stack from left-most to right-most, - * chaining each `RotorState.out` in reverse order of `in`, matching - * `Enigma.encrypt`'s return path after the reflector. + * Builds a `Rotor` from a rotor name, ring setting as a character (A–Z), and + * a character position. */ - def out(g: Glyph): Glyph = states.foldRight(g)((s, acc) => s.out(acc)) - - /** Convenience overload for chars. */ - def out(c: Char): Char = out(c.glyph).char - - /** Rotate the rotors applying Enigma carry rules for an arbitrary number of - * rotors. States are ordered from right to left (index 0 = right-most, - * highest speed): - * - The right-most rotor (index 0) always rotates. - * - A carry from rotor i after its rotation causes rotor i+1 to rotate, - * and so on. - */ - def rotate: Rotors = - val (rotated, _) = states.foldLeft((Seq[RotorState](), true)): - case ((seq, true), state) => - val next = state.rotate - (seq :+ next, next.carry) - case ((seq, false), state) => - (seq :+ state, state.carry) - Rotors(IArray.from(rotated)) - -object Rotors: - /** Convenience constructor allowing varargs of RotorState with a minimum of one. - * Right-most rotor should be provided first (index 0), matching the IArray - * ordering used by the primary case class. + def apply(rotorName: String, ring: Char, pos: Char): Rotor = + Rotor(Wheel(rotorName), ring.glyph, pos.glyph) + + /** Convenience constructor from a single settings string with the format: + * "wheel ring pos" + * + * Examples: + * - "I A A" + * - "II a b" (lowercase letters are accepted for ring/pos) + * - Extra internal whitespace is tolerated: "III A Z" + * + * @throws IllegalArgumentException + * if the format is invalid, the wheel name is unknown, or ring/pos are not + * alphabetic characters. */ - def apply(head: RotorState, tail: RotorState*): Rotors = - val arr: IArray[RotorState] = IArray.from(head +: tail.toSeq) - Rotors(arr) + def apply(settings: String): Rotor = + // Regex that tolerates leading/trailing whitespace, requires exactly three tokens, and + // enforces single alphabetic characters for ring and pos. + val Settings = """^\s*([^\s]+)\s+([A-Za-z])\s+([A-Za-z])\s*$""".r + + settings match + case Settings(name, ringStr, posStr) => + val triedRotor = for + wheel <- Wheel.unsafe(name) + ring <- Glyph.unsafe(ringStr.head) + pos <- Glyph.unsafe(posStr.head) + yield Rotor(wheel, ring, pos) + + triedRotor.get + case _ => + throw IllegalArgumentException( + "Rotor.apply requires format \"wheel ring pos\" with ring/pos as letters A-Z or a-z" + ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala new file mode 100644 index 0000000..6547b10 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala @@ -0,0 +1,153 @@ +package cryptic +package cipher +package enigma + +case class Rotors(rotors: IArray[Rotor]): + require( + rotors.nonEmpty, + "Rotors must contain at least one rotor state (right-most at index 0)" + ) + require( + rotors.map(_.wheel.name).distinct.length == rotors.length, + "Rotors state must not contain duplicate rotors" + ) + + /** Transforms a given `Glyph` through the sequence of rotors, recording the + * state of the `Glyph` at each step. + * + * @param g + * The initial `Glyph` to be transformed. + * @return + * A tuple containing the final `Glyph` after transformation and a sequence + * of `Glyph` states after each rotor's transformation, including the + * initial state. + */ + def in(g: Glyph): (Glyph, Seq[Glyph]) = + rotors.foldLeft((g, Seq(g))): (acc, rotor) => + val h = rotor.in(acc._1) + (h, acc._2 :+ h) + + /** Convenience overload for chars. */ + def in(c: Char): Char = in(c.glyph)._1.char + + /** Transforms the given `Glyph` object by applying a sequence of states in + * reverse order. + * + * @param g + * the initial `Glyph` to be transformed + * @return + * the transformed `Glyph` after applying the states + */ + def out(g: Glyph): (Glyph, Seq[Glyph]) = + rotors.foldRight((g, Seq(g))): (rotor, acc) => + val h = rotor.out(acc._1) + (h, acc._2 :+ h) + + /** Convenience overload for chars. */ + def out(c: Char): Char = out(c.glyph)._1.char + + def pos: IArray[Glyph] = rotors.map(_.pos) + + override def toString: String = s"""Rotors(${rotors + .map(_.wheel.name) + .reverse + .mkString("-")} ${pos.string.reverse})""" + + /** Rotate the rotors applying Enigma carry rules for an arbitrary number of + * rotors. States are ordered from right to left (index 0 = right-most, + * highest speed): + * - The right-most rotor (index 0) always rotates. + * - A carry from rotor i after its rotation causes rotor i+1 to rotate, + * and so on. + */ + def rotate: Rotors = + val (rotated, _) = rotors.zipWithIndex.foldLeft((Seq.empty[Rotor], true)): + case ((seq, _), (rotor, 0)) => + val next = rotor.rotate + (seq :+ next, next.carry) + case ((seq, carry), (rotor, 1)) => + // We need to handle double step anomaly + val rotated = rotor.rotate + val (c, n) = (carry, rotor.carry, rotated.carry) match + case (true, false, true) => (true, rotated) + case (true, _, true) => (false, rotated) + case (true, _, false) => (false, rotated) + case (false, _, true) => (true, rotated) + case (false, _, false) => (false, rotor) + (seq :+ n, c) + case ((seq, true), (rotor, _)) => + val rotated = rotor.rotate + (seq :+ rotated, rotated.carry) + case ((seq, false), (rotor, _)) => + (seq :+ rotor, rotor.carry) + val next = Rotors(IArray.from(rotated)) + scribe.info(s"Rotated ${pos.string.reverse} -> ${next.pos.string.reverse}") + next + +object Rotors: + /** Convenience constructor allowing varargs of RotorState with a minimum of + * one. Right-most rotor should be provided first (index 0), matching the + * IArray ordering used by the primary case class. + */ + def apply(head: Rotor, tail: Rotor*): Rotors = + val arr: IArray[Rotor] = IArray.from(head +: tail.toSeq) + Rotors(arr) + + /** Convenience constructor from a single settings string with the format: + * "names rings positions" + * + * Names are hyphen-separated rotor identifiers from left-most to right-most + * (e.g. "VI-II-I"). Ring and position are sequences of letters whose lengths + * must match the number of names, and are given left-most to right-most + * (e.g. "ABC DEF"). Lowercase letters are accepted for ring/pos and + * normalized to uppercase. + * + * Example: + * - "VI-II-I ABC DEF" => Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI + * A D")) + * + * The internal ordering of `Rotors` is right-most first (index 0), so the + * resulting sequence is reversed relative to the names/rings/positions + * provided (which are left-most to right-most). + * + * @throws IllegalArgumentException + * if the format is invalid, lengths are mismatched, names are empty, or + * ring/pos contain non-alphabetic characters, or if any wheel name is + * unknown. + */ + def apply(settings: String): Rotors = + // Tolerate leading/trailing whitespace; require exactly three whitespace-separated parts + val Settings = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s*$""".r + + settings match + case Settings(namesPart, ringsPart, posPart) => + val names = namesPart.split("-").map(_.trim).toVector + if names.isEmpty || !names.forall(_.nonEmpty) then + throw IllegalArgumentException( + "Rotors.apply requires non-empty hyphen-separated rotor names (e.g. 'VI-II-I')" + ) + + val n = names.length + if !(ringsPart.length == n && posPart.length == n) then + throw IllegalArgumentException( + s"Rotors.apply rings and positions must have length $n to match names count" + ) + + // Validate letters (regex already restricts A-Za-z but double-check defensively) + if !(ringsPart.forall(_.isLetter) && posPart.forall(_.isLetter)) then + throw IllegalArgumentException( + "Rotors.apply requires ring/pos to be letters A-Z or a-z only" + ) + + // Build rotors in right-most-first order (reverse of input order) + val rotors: Seq[Rotor] = (0 until n).reverse.map: i => + val name = names(i) + val ringCh = ringsPart.charAt(i).toUpper + val posCh = posPart.charAt(i).toUpper + Rotor(name, ringCh, posCh) + + Rotors(IArray.from(rotors)) + case _ => + throw IllegalArgumentException( + "Rotors.apply requires format \"names rings positions\" (e.g. \"VI-II-I ABC DEF\") with ring/pos as letters A-Z or a-z" + ) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala new file mode 100644 index 0000000..b9f49be --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala @@ -0,0 +1,58 @@ +package cryptic +package cipher +package enigma + +import Glyph.* + +import scala.util.Try + +case class Wheel(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): + def carry(pos: Glyph): Boolean = notches.contains(pos) + def in(g: Glyph): Glyph = wiring(g.int) + def out(g: Glyph): Glyph = Glyph(wiring.indexOf(g)) + override def toString: String = name + +object Wheel: + // Predefined rotor singletons (kept private) to preserve reference and structural equality + // while "inlining" access through the name-based apply. + private lazy val predefined: Map[String, Wheel] = + Array[ + (String, String, String) + ]( + ("I", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R"), + ("II", "AJDKSIRUXBLHWTMCQGZNPYFVOE", "F"), + ("III", "BDFHJLCPRTXVZNYEIWGAKMUSQO", "W"), + ("IV", "ESOVPZJAYQUIRHXLNFTGKDCMWB", "K"), + ("V", "VZBRGITYUPSDNHLXAWMJQOFECK", "A"), + ("VI", "JPGVOUMFYQBENHZRDKASXLICTW", "AN") + ).map: (name, wiring, notch) => + name -> Wheel(name, wiring, notch) + .toMap + + /** Construct a Rotor from notch and wiring strings. Non‑alphabetic characters + * are ignored (per `String.glyph`). + */ + def apply(name: String, wiring: String, notch: String): Wheel = + val n = notch.glyph + val w = wiring.glyph + require( + n.nonEmpty, + "Rotor notch must be non-empty (at least one valid A–Z char)" + ) + require( + n.distinct.length == n.length, + "Rotor notch characters must be non-repeating" + ) + require( + w.length == Glyph.mod, + s"Rotor wiring must have length ${Glyph.mod}" + ) + Wheel(name, w, n) + + /** Lookup a predefined rotor by its conventional name ("I" .. "VI"). */ + def apply(name: String): Wheel = predefined.getOrElse( + name, + throw IllegalArgumentException(s"Unknown rotor name: '$name'") + ) + + def unsafe(name: String): Try[Wheel] = Try(apply(name)) diff --git a/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt b/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt new file mode 100644 index 0000000..10a951d --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-AAA-AAA.txt @@ -0,0 +1 @@ +ILFDFARUBDONVISRUKOZQMNDIYCOUHRLAWBRMPYLAZNYNGRMRMVAAJLNSZFHSYBBKFODPCHQPHSWOQZCJFKXNBAZJNPZHZBGOMNXOPPXXEVJWISBPSDPJQBKZXCTHIVUOJJCSOVBSWFZLGSLFDXMWZWSMHAYRJASZSGQDMQRLKAULWOZNGHSRKVXFQLEVXDWHAWZBXVFBISAAQCNNFYJXBAWIYKWCEUOCSKMKHEQGAJDONNHVNJTADKKYRYCYITTAGPDUZMKESMIJDCHOKQOEJIOXFMKSLNBKCZGAHMMJRSFVSIYQZJGMCNEMLSOUHZAJXDRBOXVJLMNZUQAMQONCFCOKGESAVPJZESHNZBLEEJOAPXRV diff --git a/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt b/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt new file mode 100644 index 0000000..732415d --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-MAR_ADQ.txt @@ -0,0 +1 @@ +NSUVWPHJSEZVTUOFNRMZMXFZVIGODXZZTTWTOMOEFZVPTKNAGAVWTTSKVSYDVGLQLUCGETGXEVIJRYCLZNXTHHSKBTXSKPAYTFMVRUKJNXMNCLWSTUILJVAOGXODUVEEBLQWUVXCUTACJSFYKVBDIERPBVXNSGSOZDYOSASMMQMZDYEWDPCEYVIETWTEIZHKEPAMYCUCARTSOIEANLZZHCQJWGHODHOAPTZFHJHRWLJZCOTGWWZCDFRTDQGYGWJXWHCNWMQLIRYPVLUBLVWOIKWQMDLUBSZRFOVZGCDATSXIONJLRXDYVSVIEBNFGMJQJCWQQDHSKETZNJKNWPNUKOVURCPQWDUOCGYKJVZTHQDGQZJZQ \ No newline at end of file diff --git a/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt b/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt new file mode 100644 index 0000000..12e3fbf --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem-cipher-ZBQ-AAA.txt @@ -0,0 +1 @@ +DYXNTZEWFWLZTGKWHNBTBZQACIFJZQCBVQPGIKWILCQMOJZJLJVHRVXBQZSUREIBOWTEGFNTGDQFUZZNDLXUAKNVEMCPONSKZOLFBYDGWVHYQYAGKKACVGYQKSXRNOBLDZVOMJLNYLLKZRDJRQWOQPPDZLFUFEEMEBVAFUMVMUCJYZTQIROLQTFDDCCXCYLHKHPUOZCELGKDQLHKCUJMLFWPYRGLAPLUTDTSAXNYJSHLMTMPFZHVDCQJWQKBCCIJJOQNKSOHZTAOIAKIUZHTMXYYYASXAQTLIQPUZKQOUNEQQXHLXVZQWZFIRJCMTOGNRLYMMXJDVFIVWPDMDVEHYRZTJDUXHUYXFXTRXBFIQKKWPZWRR diff --git a/cipher-enigma/src/test/resources/lorem.txt b/cipher-enigma/src/test/resources/lorem.txt new file mode 100644 index 0000000..f97cb4f --- /dev/null +++ b/cipher-enigma/src/test/resources/lorem.txt @@ -0,0 +1 @@ +LoremipsumdolorsitametconsecteturadipiscingelitseddoeiusmodtemporincididuntutlaboreetdoloremagnaaliquaUtenimadminimveniamquisnostrudexercitationullamcolaborisnisiutaliquipexeacommodoconsequatDuisauteiruredolorinreprehenderitinvoluptatevelitessecillumdoloreeufugiatnullapariaturExcepteursintoccaecatcupidatatnonproidentsuntinculpaquiofficiadeseruntmollitanimidestlaborum \ No newline at end of file diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala index 49d44fb..90b6470 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -2,37 +2,50 @@ package cryptic package cipher package enigma -import cryptic.cipher.enigma.Reflector.A -import org.scalatest.flatspec.{AnyFlatSpec, AsyncFlatSpec} +import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.TryValues +import org.scalatest.prop.TableDrivenPropertyChecks -import scala.util.{Success, Try} - -class EnigmaSpec extends AnyFlatSpec with Matchers with TryValues: +class EnigmaSpec + extends AnyFlatSpec + with Matchers + with TryValues + with TableDrivenPropertyChecks: behavior of "Enigma" import Enigma.default.{given, *} import Functor.tryFunctor - "Enigma" should "encrypt MARTIN to WFSEXB" in: - given settings: Settings = Settings('Z') - val enc: Encrypted[Try, String] = - "MARTIN".encrypted - enc.bytes.success.value shouldBe "WFSEXB".bytes - - it should "encrypt A to T when Z" in: - given settings:Settings = Settings('Z') - "A".encrypted.bytes.success.value shouldBe "N".bytes - - it should "encrypt A to T when A" in: - given settings:Settings = Settings('A') - "A".encrypted.bytes.success.value shouldBe "F".bytes - - it should "encrypt A to T when B" in: - given settings:Settings = Settings('B') - "A".encrypted.bytes.success.value shouldBe "T".bytes - - it should "encrypt A to T when C" in: - given settings:Settings = Settings('C') - "A".encrypted.bytes.success.value shouldBe "Z".bytes + val lorem: String = "lorem.txt".fromResource.glyph.string + val loremCipherAAA_AAA: String = "lorem-cipher-AAA-AAA.txt".fromResource.glyph.string + val loremCipherMAR_ADQ: String = "lorem-cipher-MAR_ADQ.txt".fromResource.glyph.string + val loremCipherZBQ_AAA: String = "lorem-cipher-ZBQ-AAA.txt".fromResource.glyph.string + + "Enigma" should "encrypt and decrypt with explicit ring settings" in: + val data = Table( + ("plain", "cipher", "settings"), + ("MARTIN", "WFSEXB", "III-II-I AAA AAZ B"), + ("MARTIN", "SNJAJP", "III-II-I AAB AAZ B"), + ("MARTIN", "HTFUSP", "III-II-I AAA AAA B"), + ("MARTIN", "ITDVLW", "III-II-I AAE AAA B"), + ("ABCD", "ZUIN", "III-II-I AAA ADQ B"), + ("ABCD", "NODJ", "III-II-I AAA LEQ B"), + (lorem, loremCipherAAA_AAA, "III-II-I AAA AAA B"), + (lorem, loremCipherMAR_ADQ, "III-II-I MAR ADQ B"), + (lorem, loremCipherZBQ_AAA, "III-II-I ZBQ AAA B") + ) + forAll(data)((plain: String, cipher: String, settings: String) => + given s: Settings = Settings(settings) + verify(plain, cipher) + ) + + "Settings" should "parse with ring settings" in: + val settings = Settings("III-II-I AAA AAZ B") + settings.rotors.rotors shouldBe Rotors("III-II-I AAA AAZ ").rotors + settings.reflector shouldBe Reflector.B + + def verify(text: String, cipher: String)(using settings: Settings): Any = + val encrypted = text.encrypted + encrypted.bytes.success.value.map(_.toChar).mkString shouldBe cipher + encrypted.decrypted.success.value shouldBe text diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala index bce340e..e4c0866 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala @@ -81,3 +81,31 @@ class GlyphSpec extends AnyFlatSpec with Matchers: invalids.foreach { c => Glyph.isValid(c) shouldBe false } + + "Glyph extension methods" should "convert between Char and String" in : + val g = 'A'.glyph + g.char shouldBe 'A' + g.string shouldBe "A" + Glyph(25).string shouldBe "Z" + "ABC".glyph.string shouldBe "ABC" + Seq('A'.glyph, 'B'.glyph, 'C'.glyph).string shouldBe "ABC" + + "Rotor.previousCarry" should "be true when current pos is one after the notch (single notch)" in: + // Wheel I has notch at 'R' -> previousCarry true at pos 'S' + val r1 = Rotor("I", 'A', 'S') + r1.previousCarry shouldBe true + // At the notch itself ('R') it's false for previousCarry + Rotor("I", 'A', 'R').previousCarry shouldBe false + // One past 'S' ('T') is also false + Rotor("I", 'A', 'T').previousCarry shouldBe false + + it should "handle multiple notches correctly (VI: A and N)" in: + // Wheel VI has notches at 'A' and 'N' -> previousCarry true at 'B' and 'O' + Rotor("VI", 'A', 'B').previousCarry shouldBe true + Rotor("VI", 'A', 'O').previousCarry shouldBe true + // At the notch positions themselves, previousCarry should be false + Rotor("VI", 'A', 'A').previousCarry shouldBe false + Rotor("VI", 'A', 'N').previousCarry shouldBe false + // Other positions should be false + Rotor("VI", 'A', 'C').previousCarry shouldBe false + diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala new file mode 100644 index 0000000..213d976 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala @@ -0,0 +1,46 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorApplySpec extends AnyFlatSpec with Matchers: + behavior of "Rotor.apply(settings: String) — dedicated parsing tests" + + private val formatError = "Rotor.apply requires format \"wheel ring pos\" with ring/pos as letters A-Z or a-z" + + it should "accept leading and trailing whitespace" in: + Rotor(" I A A ") shouldBe Rotor("I", 'A', 'A') + + it should "accept tab separators as whitespace" in: + // Tabs (\t) are matched by \s in the regex + Rotor("II\tB\tC") shouldBe Rotor("II", 'B', 'C') + + it should "normalize lowercase ring/pos to uppercase" in: + Rotor("III a z") shouldBe Rotor("III", 'A', 'Z') + Rotor("IV m n") shouldBe Rotor("IV", 'M', 'N') + + it should "handle mixed spacing between tokens" in: + Rotor("V A\t\tZ") shouldBe Rotor("V", 'A', 'Z') + + it should "reject empty or malformed formats with a clear message" in: + val ex1 = intercept[IllegalArgumentException] { Rotor("") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotor("I A") } + ex2.getMessage shouldBe formatError + + val ex3 = intercept[IllegalArgumentException] { Rotor("I A A X") } + ex3.getMessage shouldBe formatError + + it should "reject non-letter ring/pos with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotor("I 1 A") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotor("I A 1") } + ex2.getMessage shouldBe formatError + + val ex3 = intercept[IllegalArgumentException] { Rotor("I å A") } + ex3.getMessage shouldBe formatError + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { Rotor("UNKNOWN A A") } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala index a79b7fe..0a509c0 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala @@ -2,51 +2,101 @@ package cryptic.cipher.enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks -class RotorSpec extends AnyFlatSpec with Matchers: - behavior of "Rotor.apply" - - it should "construct a rotor from valid notch and wiring strings" in: - val r = Rotor("Custom", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R") - r.carry('R'.glyph) shouldBe true - - it should "reject an empty (or entirely invalid) notch" in: - intercept[IllegalArgumentException] { - Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "") - } - intercept[IllegalArgumentException] { - Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "--- __") - } - - it should "reject a notch with repeating characters" in: - intercept[IllegalArgumentException] { - Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "AA") - } - - it should "require wiring length to equal Glyph.mod (26)" in: - // Too short (25) - intercept[IllegalArgumentException] { - Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXY", "A") - } - // Too long (27) - intercept[IllegalArgumentException] { - Rotor("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZA", "A") - } - - behavior of "Rotor.apply(name:String)" - - it should "return predefined rotors by name I..VI" in: - Rotor("I").toString shouldBe "I" - Rotor("II").toString shouldBe "II" - Rotor("III").toString shouldBe "III" - Rotor("IV").toString shouldBe "IV" - Rotor("V").toString shouldBe "V" - Rotor("VI").toString shouldBe "VI" - - it should "throw for unknown rotor names" in: - intercept[IllegalArgumentException] { - Rotor("VII") - } - intercept[IllegalArgumentException] { - Rotor("") - } +class RotorSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + behavior of "Rotor" + + "Rotor" should "map and carry after rotate" in: + val data = Table( + ("Settings", "plain", "cipher", "carry"), + ("I A Z", 'A', 'E', false), + ("I A Z", 'B', 'K', false), + ("I A Z", 'E', 'L', false), + ("I A Z", 'J', 'Z', false), + ("I A Z", 'S', 'S', false), + ("I A A", 'A', 'J', false), + ("I A A", 'B', 'L', false), + ("I A B", 'A', 'K', false), + ("I A B", 'B', 'D', false), + ("I B Z", 'A', 'K', false), + ("I B B", 'A', 'J', false), + ("I B Q", 'A', 'H', true), + ("I Z Q", 'A', 'A', true), + ("I A Q", 'A', 'D', true), + ("I B R", 'A', 'D', false), + ("I C S", 'A', 'D', false), + ("I Z P", 'A', 'D', false), + ("I Y O", 'A', 'D', false), + ("I B Q", 'M', 'W', true), + ("I B Q", 'A', 'H', true), + ("II A Z", 'D', 'K', false), + ("III A Z", 'K', 'X', false) + ) + forAll(data): + (settings: String, plain: Char, cipher: Char, carry: Boolean) => + val rotor = Rotor(settings).rotate // We rotate before mapping + rotor.in(plain) shouldBe cipher + rotor.out(cipher) shouldBe plain + rotor.carry shouldBe carry + + private val rotorA = Rotor("I A A") + private val rotorB = Rotor("I A B") + private val rotorC = Rotor("I A C") + private val rotorQ = Rotor("I A Q") + private val rotorBQ = Rotor("I B Q") + + it should "rotate" in: + val data = Table( + ("before", "after"), + ("I A A", "I A B"), + ("I A B", "I A C") + ) + forAll(data): (before, after) => + Rotor(before).rotate shouldBe Rotor(after) + + it should "have carry on notch" in: + val data = Table( + ("rotor", "carry"), + ("I A A", false), + ("I A Q", false), + ("I A R", true), + ("I B Q", false), + ("I B R", true), + ("I B S", false), + ("VI A N", true), + ("VI A Z", false), + ("VI A B", false), + ("VI A M", false), + ("VI A O", false), + ("II A A", false) + ) + forAll(data): (rotor: String, carry: Boolean) => + Rotor(rotor).carry shouldBe carry + + behavior of "Rotor.apply(settings: String)" + + it should "parse \"wheel ring pos\" with spaces" in: + Rotor("I A A") shouldBe Rotor("I", 'A', 'A') + Rotor("II B C") shouldBe Rotor("II", 'B', 'C') + + it should "accept lowercase ring/pos and normalize" in: + Rotor("III a z") shouldBe Rotor("III", 'A', 'Z') + + it should "tolerate extra internal whitespace" in: + Rotor("IV A Z") shouldBe Rotor("IV", 'A', 'Z') + + it should "reject wrong token counts" in: + intercept[IllegalArgumentException] { Rotor("") } + intercept[IllegalArgumentException] { Rotor("I A") } + intercept[IllegalArgumentException] { Rotor("I A A X") } + + it should "reject non-letter ring/pos" in: + intercept[IllegalArgumentException] { Rotor("I 1 A") } + intercept[IllegalArgumentException] { Rotor("I A 1") } + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { Rotor("VII A A") } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala deleted file mode 100644 index 7596049..0000000 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorStateSpec.scala +++ /dev/null @@ -1,81 +0,0 @@ -package cryptic.cipher.enigma - -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class RotorStateSpec extends AnyFlatSpec with Matchers: - behavior of "RotorState" - - private val rotorStateA = RotorState(Rotor("I"), Glyph(0), 'A'.glyph) - private val rotorStateB = RotorState(Rotor("I"), Glyph(0), 'B'.glyph) - private val rotorStateC = RotorState(Rotor("I"), Glyph(0), 'C'.glyph) - private val rotorStateQ = RotorState(Rotor("I"), Glyph(0), 'Q'.glyph) - - "Rotor I, ring 0, pos A" should "have a nice toString" in: - rotorStateA.toString shouldBe "RotorState(I, 00, A)" - - it should "map inputs" in: - rotorStateA.in('A') shouldBe 'E' - rotorStateA.in('E') shouldBe 'L' - rotorStateA.in('B') shouldBe 'K' - rotorStateA.in('J') shouldBe 'Z' - - it should "map outputs" in: - rotorStateA.out('E') shouldBe 'A' - rotorStateA.out('L') shouldBe 'E' - rotorStateA.out('K') shouldBe 'B' - rotorStateA.out('Z') shouldBe 'J' - - it should "have carry = false" in: - rotorStateA.carry shouldBe false - - it should "rotate" in: - rotorStateA.rotate shouldBe rotorStateB - rotorStateB.rotate shouldBe rotorStateC - - "Rotor I pos B" should "map in" in: - rotorStateB.in('A') shouldBe 'J' - rotorStateB.in('B') shouldBe 'L' - rotorStateB.in('C') shouldBe 'E' - rotorStateB.in('J') shouldBe 'M' - rotorStateB.in('Z') shouldBe 'D' - rotorStateB.in('V') shouldBe 'A' - - it should "map out" in: - rotorStateB.out('J') shouldBe 'A' - rotorStateB.out('L') shouldBe 'B' - rotorStateB.out('E') shouldBe 'C' - rotorStateB.out('M') shouldBe 'J' - rotorStateB.out('D') shouldBe 'Z' - rotorStateB.out('A') shouldBe 'V' - - "Rotor I pos C" should "map in" in: - rotorStateC.in('A') shouldBe 'K' - rotorStateC.in('B') shouldBe 'D' - rotorStateC.in('R') shouldBe 'N' - rotorStateC.in('S') shouldBe 'Y' - rotorStateC.in('Y') shouldBe 'C' - rotorStateC.in('Z') shouldBe 'I' - - it should "map out" in: - rotorStateC.out('K') shouldBe 'A' - rotorStateC.out('D') shouldBe 'B' - rotorStateC.out('N') shouldBe 'R' - rotorStateC.out('I') shouldBe 'Z' - rotorStateC.out('Y') shouldBe 'S' - - "Rotor I, ring 0, pos Q" should "map input B to K" in: - rotorStateQ.in('B') shouldBe 'E' - - "Rotor I" should "rotate and carry" in: - val nextState = rotorStateQ.rotate - nextState shouldBe RotorState(Rotor("I"), Glyph(0), 'R'.glyph) - nextState.carry shouldBe true - - "Rotor VI" should "have double notch" in: - RotorState(Rotor("VI"), Glyph(0), 'A'.glyph).carry shouldBe true - RotorState(Rotor("VI"), Glyph(0), 'N'.glyph).carry shouldBe true - RotorState(Rotor("VI"), Glyph(0), 'Z'.glyph).carry shouldBe false - RotorState(Rotor("VI"), Glyph(0), 'B'.glyph).carry shouldBe false - RotorState(Rotor("VI"), Glyph(0), 'M'.glyph).carry shouldBe false - RotorState(Rotor("VI"), Glyph(0), 'O'.glyph).carry shouldBe false diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala new file mode 100644 index 0000000..441e02c --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala @@ -0,0 +1,56 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class RotorsApplySpec extends AnyFlatSpec with Matchers: + behavior of "Rotors.apply(settings: String)" + + private val formatError = + "Rotors.apply requires format \"names rings positions\" (e.g. \"VI-II-I ABC DEF\") with ring/pos as letters A-Z or a-z" + + it should "parse the example 'VI-II-I ABC DEF' into right-most-first order" in: + val r = Rotors("VI-II-I ABC DEF") + val expected = Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI A D")) + r.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "accept arbitrary rotor counts and normalize lowercase ring/pos" in: + val r = Rotors("IV-III-II-I abcd efgh") + r.rotors.length shouldBe 4 + // Expected right-most-first + val expected = Rotors( + Rotor("I D H"), + Rotor("II C G"), + Rotor("III B F"), + Rotor("IV A E") + ) + r.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "tolerate leading/trailing whitespace and mixed internal spacing" in: + val r1 = Rotors(" VI-II-I ABC DEF ") + val r2 = Rotors("VI-II-I\tABC\tDEF") + val expected = Rotors(Rotor("I C F"), Rotor("II B E"), Rotor("VI A D")) + r1.rotors.toSeq shouldBe expected.rotors.toSeq + r2.rotors.toSeq shouldBe expected.rotors.toSeq + + it should "reject mismatched lengths with a clear message" in: + val ex = intercept[IllegalArgumentException] { Rotors("I-II ABC DE") } + ex.getMessage shouldBe "Rotors.apply rings and positions must have length 2 to match names count" + + it should "reject malformed formats with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotors("") } + ex1.getMessage shouldBe formatError + + val ex2 = intercept[IllegalArgumentException] { Rotors("I-II ABC") } + ex2.getMessage shouldBe formatError + + it should "reject non-letter ring/pos with the format error message" in: + val ex1 = intercept[IllegalArgumentException] { Rotors("I 1 A") } + ex1.getMessage shouldBe formatError + val ex2 = intercept[IllegalArgumentException] { Rotors("I A 1") } + ex2.getMessage shouldBe formatError + + it should "reject unknown wheel names via Wheel" in: + intercept[IllegalArgumentException] { + Rotors("UNKNOWN-I AB CD") + } diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala index 4c31de8..f12accc 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala @@ -2,51 +2,53 @@ package cryptic.cipher.enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks -class RotorsSpec extends AnyFlatSpec with Matchers: +class RotorsSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: behavior of "Rotors.rotate" - it should "rotate the right rotor only when no carry occurs (3 rotors)" in: - val right0 = RotorState("I", 0, 'A') // Rotor I: will move to B, no carry (notch = R) - val middle0 = RotorState("II", 0, 'A') // Different rotor to satisfy uniqueness - val left0 = RotorState("III", 0, 'A') - val rotors0 = Rotors(IArray(right0, middle0, left0)) - - val rotors1 = rotors0.rotate + it should "rotate 3 rotors" in: + val data = Table( + // Order is right to left, where left is at index 0 + // We do to rotations to check for the double step anomaly + // of the middle rotor + ("Before", "First", "Second"), + ("JAA", "JAB", "JAC"), // Only left rotor + ("KAQ", "KBR", "KBS"), // left R carry => middle rotate + ("LEQ", "MFR", "MFS"), // All three + ("MDQ", "MER", "NFS"), // Double step anomaly + ("NER", "OFS", "OFT") // Double step anomaly + ) + forAll(data): (before: String, first: String, second: String) => + val rotate1 = Rotors(s"III-II-I AAA $before").rotate + withClue(s"Failed after 1st rotation starting from $before: ") { + rotate1.pos.string.reverse shouldBe first + } + val rotate2 = rotate1.rotate + withClue(s"Failed after 2nd rotation starting from ${rotate1.pos.string.reverse}: ") { + rotate2.pos.string.reverse shouldBe second + } - rotors1.states(0).pos shouldBe 'B'.glyph // right advanced - rotors1.states(1).pos shouldBe 'A'.glyph // middle unchanged - rotors1.states(2).pos shouldBe 'A'.glyph // left unchanged + it should "rotate the right rotor only when no carry occurs (3 rotors)" in: + val rotors = Rotors("III-II-I AAA AAA").rotate + rotors.pos.string.reverse shouldBe "AAB" it should "rotate middle when right carries after its rotation (3 rotors)" in: - val right0 = RotorState("I", 0, 'Q') // rotate -> R, which carries for Rotor I - val middle0 = RotorState("II", 0, 'A') // will rotate to B (no carry for Rotor II) - val left0 = RotorState("III", 0, 'A') - val rotors0 = Rotors(IArray(right0, middle0, left0)) - - val rotors1 = rotors0.rotate - - rotors1.states(0).pos shouldBe 'R'.glyph // right advanced and at notch - rotors1.states(1).pos shouldBe 'B'.glyph // middle advanced due to carry - rotors1.states(2).pos shouldBe 'A'.glyph // left unchanged (no carry from middle yet) + val rotors = Rotors("III-II-I AAA AAQ").rotate + rotors.pos.string.reverse shouldBe "ABR" it should "rotate left when middle carries after its rotation (double step trigger, 3 rotors)" in: - val right0 = RotorState("I", 0, 'Q') // rotate -> R and carries, causing middle rotate - val middle0 = RotorState("II", 0, 'E') // when rotated (due to right carry) -> F (Rotor II notch) and carries - val left0 = RotorState("III", 0, 'A') - val rotors0 = Rotors(IArray(right0, middle0, left0)) - - val rotors1 = rotors0.rotate - - rotors1.states(0).pos shouldBe 'R'.glyph // right advanced - rotors1.states(1).pos shouldBe 'F'.glyph // middle advanced to notch (Rotor II) - rotors1.states(2).pos shouldBe 'B'.glyph // left advanced due to middle carry + val rotors = Rotors("III-II-I AAA AEQ").rotate + rotors.pos.string.reverse shouldBe "BFR" it should "work with a single rotor (always rotates)" in: - val single0 = Rotors(IArray(RotorState("I", 0, 'A'))) + val single0 = Rotors(IArray(Rotor("I A A"))) val single1 = single0.rotate - single1.states.length shouldBe 1 - single1.states(0).pos shouldBe 'B'.glyph + single1.rotors.length shouldBe 1 + single1.rotors(0).pos shouldBe 'B'.glyph it should "ripple carry across four rotors" in: // Set up a chain where first rotation causes cascading carries: @@ -54,46 +56,53 @@ class RotorsSpec extends AnyFlatSpec with Matchers: // Next (Rotor II) at E -> when rotated to F, carries (Rotor II notch F) // Next (Rotor III) at V -> when rotated to W, carries (Rotor III notch W) // Leftmost (Rotor IV) at A -> should rotate to B due to carry chain - val r0 = RotorState("I", 0, 'Q') - val r1 = RotorState("II", 0, 'E') - val r2 = RotorState("III", 0, 'V') - val r3 = RotorState("IV", 0, 'A') + val r0 = Rotor("I A Q") + val r1 = Rotor("II A E") + val r2 = Rotor("III A V") + val r3 = Rotor("IV A A") val rotors0 = Rotors(IArray(r0, r1, r2, r3)) val rotors1 = rotors0.rotate - rotors1.states.map(_.pos) shouldBe IArray('R'.glyph, 'F'.glyph, 'W'.glyph, 'B'.glyph) + rotors1.rotors + .map(_.pos) shouldBe IArray('R'.glyph, 'F'.glyph, 'W'.glyph, 'B'.glyph) behavior of "Rotors constructor" it should "require at least one rotor state and accept arbitrary sizes" in: // empty should fail intercept[IllegalArgumentException] { - Rotors(IArray.empty[RotorState]) + Rotors(IArray.empty[Rotor]) } // 1, 2, 3, 4 should all be allowed - noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'))) - noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'))) - noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("III", 0, 'A'))) - noException should be thrownBy Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("III", 0, 'A'), RotorState("IV", 0, 'A'))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"))) + noException should be thrownBy Rotors( + IArray(Rotor("I A A"), Rotor("II A A")) + ) + noException should be thrownBy Rotors( + IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A")) + ) + noException should be thrownBy Rotors( + IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"), Rotor("IV A A")) + ) it should "reject duplicate rotors (by identity/name)" in: // Duplicate rotor I used twice should fail intercept[IllegalArgumentException] { - Rotors(IArray(RotorState("I", 0, 'A'), RotorState("I", 0, 'B'))) + Rotors(IArray(Rotor("I A A"), Rotor("I A B"))) } // Duplicates among more than two should also fail intercept[IllegalArgumentException] { - Rotors(IArray(RotorState("I", 0, 'A'), RotorState("II", 0, 'A'), RotorState("I", 0, 'C'))) + Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("I A C"))) } behavior of "Rotors.in" it should "chain RotorState.in from right-most to left-most (3 rotors)" in: - val r0 = RotorState("I", 0, 'A') - val r1 = RotorState("II", 0, 'A') - val r2 = RotorState("III", 0, 'A') + val r0 = Rotor("I A A") + val r1 = Rotor("II A A") + val r2 = Rotor("III A A") val rotors = Rotors(IArray(r0, r1, r2)) - val input = 'A'.glyph + val input = 'A' val step1 = r0.in(input) val step2 = r1.in(step1) val step3 = r2.in(step2) @@ -101,43 +110,44 @@ class RotorsSpec extends AnyFlatSpec with Matchers: rotors.in(input) shouldBe step3 it should "match single rotor behavior and support char overload" in: - val single = Rotors(IArray(RotorState("I", 0, 'A'))) + val single = Rotors(IArray(Rotor("I A A"))) val g = 'C'.glyph - single.in(g) shouldBe single.states(0).in(g) - single.in('C') shouldBe single.in(g).char + single.in(g) shouldBe (single.rotors(0).in(g), List(2, 12)) + single.in('C') shouldBe single.in(g.char) behavior of "Rotors companion object apply" it should "construct from varargs with at least one state (single)" in: - val single = Rotors(RotorState("I", 0, 'A')) - single.states.length shouldBe 1 - single.states(0).pos shouldBe 'A'.glyph + val single = Rotors(Rotor("I A A")) + single.rotors.length shouldBe 1 + single.rotors(0).pos shouldBe 'A'.glyph it should "construct from varargs with multiple states and preserve order" in: - val r0 = RotorState("I", 0, 'A') - val r1 = RotorState("II", 0, 'B') - val r2 = RotorState("III", 0, 'C') + val r0 = Rotor("I A A") + val r1 = Rotor("II A B") + val r2 = Rotor("III A C") val rotors = Rotors(r0, r1, r2) - rotors.states.length shouldBe 3 - rotors.states(0) shouldBe r0 - rotors.states(1) shouldBe r1 - rotors.states(2) shouldBe r2 + rotors.rotors.length shouldBe 3 + rotors.rotors(0) shouldBe r0 + rotors.rotors(1) shouldBe r1 + rotors.rotors(2) shouldBe r2 + it should "reject duplicates when constructed via varargs" in: intercept[IllegalArgumentException] { - Rotors(RotorState("I", 0, 'A'), RotorState("I", 0, 'B')) + Rotors(Rotor("I A A"), Rotor("I A B")) } behavior of "Rotors.out" it should "chain RotorState.out from left-most to right-most (3 rotors)" in: - val r0 = RotorState("I", 0, 'A') - val r1 = RotorState("II", 0, 'A') - val r2 = RotorState("III", 0, 'A') + val r0 = Rotor("I A A") + val r1 = Rotor("II A A") + val r2 = Rotor("III A A") val rotors = Rotors(IArray(r0, r1, r2)) - val input = 'Z'.glyph + val input = 'Z' // Out path is left-most to right-most: r2, then r1, then r0 val step1 = r2.out(input) val step2 = r1.out(step1) @@ -146,8 +156,8 @@ class RotorsSpec extends AnyFlatSpec with Matchers: rotors.out(input) shouldBe step3 it should "match single rotor behavior and support char overload" in: - val single = Rotors(IArray(RotorState("I", 0, 'A'))) + val single = Rotors(IArray(Rotor("I A A"))) val g = 'M'.glyph - single.out(g) shouldBe single.states(0).out(g) - single.out('M') shouldBe single.out(g).char + single.out(g) shouldBe (single.rotors(0).out(g), List(12, 2)) + single.out('M') shouldBe single.out(g.char) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala new file mode 100644 index 0000000..2035d06 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala @@ -0,0 +1,52 @@ +package cryptic.cipher.enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class WheelSpec extends AnyFlatSpec with Matchers: + behavior of "Rotor.apply" + + it should "construct a rotor from valid notch and wiring strings" in: + val r = Wheel("Custom", "EKMFLGDQVZNTOWYHXUSPAIBRCJ", "R") + r.carry('R'.glyph) shouldBe true + + it should "reject an empty (or entirely invalid) notch" in: + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "") + } + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "--- __") + } + + it should "reject a notch with repeating characters" in: + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "AA") + } + + it should "require wiring length to equal Glyph.mod (26)" in: + // Too short (25) + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXY", "A") + } + // Too long (27) + intercept[IllegalArgumentException] { + Wheel("Custom", "ABCDEFGHIJKLMNOPQRSTUVWXYZA", "A") + } + + behavior of "Rotor.apply(name:String)" + + it should "return predefined rotors by name I..VI" in: + Wheel("I").toString shouldBe "I" + Wheel("II").toString shouldBe "II" + Wheel("III").toString shouldBe "III" + Wheel("IV").toString shouldBe "IV" + Wheel("V").toString shouldBe "V" + Wheel("VI").toString shouldBe "VI" + + it should "throw for unknown rotor names" in: + intercept[IllegalArgumentException] { + Wheel("VII") + } + intercept[IllegalArgumentException] { + Wheel("") + } diff --git a/cipher-javax/src/main/scala/cryptic/cipher/javax.scala b/cipher-javax/src/main/scala/cryptic/cipher/javax.scala index 15f4f85..a272579 100644 --- a/cipher-javax/src/main/scala/cryptic/cipher/javax.scala +++ b/cipher-javax/src/main/scala/cryptic/cipher/javax.scala @@ -1,8 +1,6 @@ package cryptic package cipher -import org.bouncycastle.math.ec.rfc8032.Ed25519.Algorithm - import java.security.SecureRandom import javax.crypto.{KeyGenerator, SecretKey, SecretKeyFactory} import javax.crypto.spec.{PBEKeySpec, SecretKeySpec} diff --git a/core/src/main/scala/cryptic/core.scala b/core/src/main/scala/cryptic/core.scala index 67895e2..6988ca1 100644 --- a/core/src/main/scala/cryptic/core.scala +++ b/core/src/main/scala/cryptic/core.scala @@ -2,9 +2,9 @@ package cryptic import java.nio.ByteBuffer import scala.annotation.targetName -import scala.concurrent.Future +import scala.io.Source import scala.reflect.ClassTag -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Try} /** Identity type alias used to represent pure (non-effectful) values. */ type Id[A] = A @@ -59,7 +59,7 @@ object PlainText: /** UTF-8 encodes a string into PlainText with an empty aad. */ def apply(x: String): PlainText = apply(x, AAD.empty) - /** UTF-8 encodes a string into PlainText with a aad. */ + /** UTF-8 encodes a string into PlainText with an aad. */ def apply(x: String, aad: AAD): PlainText = apply(x.getBytes().immutable, aad) @@ -288,6 +288,7 @@ extension (str: String) def aad: AAD = str.getBytes.aad @targetName("stringToBytes") def bytes: IArray[Byte] = str.getBytes.immutable + def fromResource:String = Source.fromResource(str).slurp extension (array: IArray[Byte]) /** Mutable Array view over an IArray[Byte]. */ @@ -369,3 +370,11 @@ extension (d: Double) buffer.putDouble(d) buffer.array().immutable def aad: AAD = AAD(bytes) + +extension (source: Source) + def using[A](f: Source => A): A = + try + f(source) + finally + source.close() + def slurp: String = source.using(_.mkString) diff --git a/core/src/test/scala/cryptic/AADParameterizedSpec.scala b/core/src/test/scala/cryptic/AADParameterizedSpec.scala new file mode 100644 index 0000000..1a582e1 --- /dev/null +++ b/core/src/test/scala/cryptic/AADParameterizedSpec.scala @@ -0,0 +1,30 @@ +package cryptic + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks + +class AADParameterizedSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: + + "AAD extensions" should "correctly round-trip different data types" in: + val stringData = Table( + ("input", "expectedLength"), // Headers + ("hello", 5), + ("secret payload", 14), + ("", 0) + ) + + forAll(stringData)((input: String, expectedLength: Int) => + val aad = input.aad + + aad.string shouldBe input + aad.bytes.length shouldBe expectedLength + ) + + it should "correctly round-trip numeric types" in: + val intData = List(1, 0, -999, Int.MaxValue) + + for number <- intData do number.aad.int shouldBe number From fab0daa9ddc800e8e53234729ba9b447b1a21aae Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 30 Nov 2025 21:29:52 +0100 Subject: [PATCH 03/13] Refactor Enigma core and supporting components: - Enhanced `Settings` to handle rotor position adjustments and improve parsing logic for varied input formats. - Updated `Enigma` encryption and decryption flow to include random positions and keys in messages, to follow German army practice. - Optimized `Glyph` with random glyph generation and added byte conversion extensions. - Refined `Rotors` to expose size, position updates, and enhance stepping mechanism with improved logging. - Adjusted test cases to validate new settings, encryption flow, and message preambles. --- .../scala/cryptic/cipher/enigma/Enigma.scala | 104 +++++++++++++----- .../scala/cryptic/cipher/enigma/Glyph.scala | 31 ++++-- .../scala/cryptic/cipher/enigma/Rotors.scala | 56 +++++++--- .../cryptic/cipher/enigma/EnigmaSpec.scala | 23 +++- 4 files changed, 157 insertions(+), 57 deletions(-) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 7d2a4c5..30b50d6 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -4,64 +4,112 @@ package enigma import scala.util.{Success, Try} -case class Settings(rotors: Rotors, reflector: Reflector) +case class Settings(rotors: Rotors, reflector: Reflector): + /** + * Adjusts the rotor positions using the given array of glyphs and returns an updated Settings object. + * + * @param pos An immutable array of Glyph objects representing the rotor positions to be set. + * @return A new Settings instance with the updated rotor positions. + */ + def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) object Settings: - /** Convenience constructor parsing a full settings string including reflector. + /** Parses a settings string and constructs a `Settings` instance representing + * the rotor and reflector configurations for an Enigma machine. * - * Expected format (whitespace tolerant): - * "names rings positions reflector" + * The input settings string must match one of the following formats: + * - `"names rings positions reflector"` (e.g., `"III-II-I AAA AAZ B"`) + * - `"names rings reflector"` (e.g., `"III-II-I AAA B"`), in which case + * positions will default to all 'A'. * - * Examples: - * - "III-II-I AAA AAZ B" - * - "VI-II-I ABC DEF C" + * @param settings + * the input string specifying the rotor names, ring settings, (optional) + * initial positions, and reflector. Valid formats: + * `"names rings positions reflector"` or `"names rings reflector"`. + * - `names` are hyphen-separated rotor IDs (left-to-right order). + * - `rings` and `positions` are sequences of letters where each length + * matches the number of rotor names. + * - `reflector` is a single letter (e.g., `B`). * - * Notes: - * - Rotor names are hyphen-separated, left-most to right-most. - * - Ring and position sequences are letters (A–Z or a–z), also left-most to right-most. - * - Reflector is mandatory and must be one of A, B, C (case-insensitive allowed via enum valueOf with uppercasing). + * Example: "III-II-I AAA AAZ B" or "III-II-I AAA B". + * @return + * a `Settings` object representing the parsed rotor and reflector + * configurations. + * @throws IllegalArgumentException + * if the input does not adhere to the expected format, if the lengths of + * `names`, `rings`, or `positions` are mismatched, or if any values are + * invalid (e.g., unknown rotor names, invalid reflector). */ def apply(settings: String): Settings = // Match four parts: names, rings, positions, reflector - val SettingsRe = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r + val WithPos = + """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r + val NoPos = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r settings match - case SettingsRe(namesPart, ringsPart, posPart, reflPart) => - // Build Rotors from the first three parts using existing parser + case WithPos(namesPart, ringsPart, posPart, reflPart) => val rotors = Rotors(s"$namesPart $ringsPart $posPart") - // Reflector must be a single letter A/B/C; tolerate full word matching enum name as well + val reflector = Reflector.valueOf(reflPart.toUpperCase) + Settings(rotors, reflector) + case NoPos(namesPart, ringPart, reflPart) => + val rotors = Rotors(s"$namesPart $ringPart ${"A" * ringPart.length}") val reflector = Reflector.valueOf(reflPart.toUpperCase) Settings(rotors, reflector) case _ => throw IllegalArgumentException( - "Settings.apply requires format \"names rings positions reflector\" (e.g. \"III-II-I AAA AAZ B\")" + "Settings.apply requires format \"names rings [positions] reflector\" (e.g. \"III-II-I AAA AAZ B\")" ) object Enigma: + import Functor.tryFunctor + object default: // Expose common givens and types when using this cipher's default package export cryptic.default.{given, *} export Enigma.{given, *} - given encrypt(using settings: Settings): Encrypt[Try] = - (plaintext: PlainText) => Success(CipherText(run(plaintext.bytes))) + given encrypt(using settings: String): Encrypt[Try] = + (plaintext: PlainText) => + val base = Settings(settings) + val n = base.rotors.size + val start = Glyph.random(n) + val key = Glyph.random(n) + val encryptedKey = run(key ++ key)(using base.pos(start)) + val encryptedMessage = run(plaintext.bytes)(using base.pos(key)) + val cipher = + CipherText(start.iarray, encryptedKey.iarray, encryptedMessage.iarray) + Success(cipher) - given decrypt(using settings: Settings): Decrypt[Try] = - (cipherText: CipherText) => Success(PlainText(run(cipherText.bytes))) + given decrypt(using settings: String): Decrypt[Try] = + (_: CipherText).splitWith: + case IArray(startBytes, encryptedKeyBytes, encryptedMessageBytes) => + val base = Settings(settings) + val n = base.rotors.size + val start = glyph(startBytes) + val encryptedKey = glyph(encryptedKeyBytes) + val encryptedMessage = glyph(encryptedMessageBytes) + val doubleKey = run(encryptedKey)(using base.pos(start)) + val key = doubleKey.take(n) + val message = run(encryptedMessage)(using base.pos(key)) + Try(PlainText(message.string)) - def run(bytes: IArray[Byte])(using settings: Settings): IArray[Byte] = + def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run( + glyph(message) + ) + + def run(message: IArray[Glyph])(using settings: Settings): IArray[Glyph] = var rotors = settings.rotors - bytes - .map(_.toChar) - .filter(_.isGlyph) - .map: c => - val g = c.glyph + message + .map: g => // Rotate all rotors applying ripple-carry rules rotors = rotors.rotate // Forward through rotors, reflect, then backward out val (forward, inTrace) = rotors.in(g) val reflected = settings.reflector.reflect(forward) val (output, outTrace) = rotors.out(reflected) - scribe.debug(s"encoded ${inTrace.string}-${outTrace.string}") - output.char.toByte + scribe.trace(s"encoded ${inTrace.string}-${outTrace.string}") + output + + def glyph(bytes: IArray[Byte]): IArray[Glyph] = + new String(bytes.mutable).glyph diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala index 523880a..8111082 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -2,8 +2,10 @@ package cryptic package cipher package enigma +import java.security.SecureRandom import scala.util.{Failure, Success, Try} +val secureRandom = SecureRandom() opaque type Glyph = Int object Glyph: @@ -12,6 +14,8 @@ object Glyph: val one: Glyph = Glyph(1) def apply(i: Int): Glyph = (i + mod) % mod def apply(c: Char): Glyph = apply(c.intValue - base) + def random(n: Int): IArray[Glyph] = + IArray.from((0 until n).map(_ => Glyph(secureRandom.nextInt(mod)))) /** Safely construct a Glyph from a Char. * - Accepts A–Z or a–z; lower-case is converted to upper-case first. @@ -23,14 +27,16 @@ object Glyph: if upper >= 'A' && upper <= 'Z' then Success(apply(upper)) else Failure(IllegalArgumentException(s"Invalid character for Glyph: '$c'")) - /** Returns true if the provided character can be converted to a Glyph according - * to the same rules as `unsafe` (i.e., letters A–Z/a–z only). + /** Returns true if the provided character can be converted to a Glyph + * according to the same rules as `unsafe` (i.e., letters A–Z/a–z only). */ def isValid(c: Char): Boolean = Glyph.unsafe(c).isSuccess extension (g: Glyph) def char: Char = (g + Glyph.base).toChar - def string:String = char.toString + def string: String = char.toString + def byte: Byte = (g + Glyph.base).byteValue + /** Underlying 0-25 index value of this Glyph */ def int: Int = g def +(term: Glyph): Glyph = Glyph(g + term) @@ -58,12 +64,13 @@ extension (ga: IArray[Glyph]) */ def string: String = ga.map(_.char).mkString -extension (seq: Seq[Glyph]) - /** - * Constructs a string by concatenating the `char` representation of elements - * in the sequence `seq`. - * - * @return A string composed of the characters obtained from the `char` property - * of each element in the sequence `seq`. - */ - def string: String = seq.map(_.char).mkString +extension (iter: Iterable[Glyph]) + /** Constructs a string by concatenating the `char` representation of elements + * in the sequence `seq`. + * + * @return + * A string composed of the characters obtained from the `char` property of + * each element in the sequence `seq`. + */ + def string: String = iter.map(_.char).mkString + def iarray: IArray[Byte] = IArray.from(iter.map(_.byte)) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala index 6547b10..bd68878 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala @@ -12,6 +12,8 @@ case class Rotors(rotors: IArray[Rotor]): "Rotors state must not contain duplicate rotors" ) + val size: Int = rotors.length + /** Transforms a given `Glyph` through the sequence of rotors, recording the * state of the `Glyph` at each step. * @@ -46,20 +48,48 @@ case class Rotors(rotors: IArray[Rotor]): /** Convenience overload for chars. */ def out(c: Char): Char = out(c.glyph)._1.char + /** + * Retrieves the current positions of all rotors in the rotor sequence. + * + * @return + * An immutable array of `Glyph` representing the positions of the rotors. + */ def pos: IArray[Glyph] = rotors.map(_.pos) + /** + * Updates the positions of the rotors in the sequence to the specified positions. + * + * @param pos + * An immutable array of `Glyph` values representing the new positions for the rotors. + * The length of this array must match the number of rotors (`size`). + * @return + * A new `Rotors` instance with the updated rotor positions. + * @throws IllegalArgumentException + * If the length of the `pos` array does not match the number of rotors. + */ + def pos(pos: IArray[Glyph]): Rotors = + require(pos.length == size, "Length of pos must match the number of rotors.") + val reposed = rotors + .zip(pos) + .map: + case (r, g) => r.copy(pos = g) + copy(rotors = IArray.from(reposed)) + override def toString: String = s"""Rotors(${rotors .map(_.wheel.name) .reverse .mkString("-")} ${pos.string.reverse})""" - /** Rotate the rotors applying Enigma carry rules for an arbitrary number of - * rotors. States are ordered from right to left (index 0 = right-most, - * highest speed): - * - The right-most rotor (index 0) always rotates. - * - A carry from rotor i after its rotation causes rotor i+1 to rotate, - * and so on. - */ + /** + * Rotates the sequence of rotors to their next positions according to Enigma's stepping mechanism, + * including handling the "double-stepping" anomaly. + * + * The method progresses through the rotors, applying the rotation based on their individual states + * and carry state, updating their positions appropriately in sequence. + * + * @return + * A new instance of `Rotors` representing the updated state after rotation. + */ def rotate: Rotors = val (rotated, _) = rotors.zipWithIndex.foldLeft((Seq.empty[Rotor], true)): case ((seq, _), (rotor, 0)) => @@ -69,11 +99,11 @@ case class Rotors(rotors: IArray[Rotor]): // We need to handle double step anomaly val rotated = rotor.rotate val (c, n) = (carry, rotor.carry, rotated.carry) match - case (true, false, true) => (true, rotated) - case (true, _, true) => (false, rotated) - case (true, _, false) => (false, rotated) - case (false, _, true) => (true, rotated) - case (false, _, false) => (false, rotor) + case (true, false, true) => (true, rotated) + case (true, _, true) => (false, rotated) + case (true, _, false) => (false, rotated) + case (false, _, true) => (true, rotated) + case (false, _, false) => (false, rotor) (seq :+ n, c) case ((seq, true), (rotor, _)) => val rotated = rotor.rotate @@ -81,7 +111,7 @@ case class Rotors(rotors: IArray[Rotor]): case ((seq, false), (rotor, _)) => (seq :+ rotor, rotor.carry) val next = Rotors(IArray.from(rotated)) - scribe.info(s"Rotated ${pos.string.reverse} -> ${next.pos.string.reverse}") + scribe.trace(s"Rotated ${pos.string.reverse} -> ${next.pos.string.reverse}") next object Rotors: diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala index 90b6470..1de56e0 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -7,6 +7,8 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.TryValues import org.scalatest.prop.TableDrivenPropertyChecks +import scala.util.{Success, Try} + class EnigmaSpec extends AnyFlatSpec with Matchers @@ -22,7 +24,7 @@ class EnigmaSpec val loremCipherMAR_ADQ: String = "lorem-cipher-MAR_ADQ.txt".fromResource.glyph.string val loremCipherZBQ_AAA: String = "lorem-cipher-ZBQ-AAA.txt".fromResource.glyph.string - "Enigma" should "encrypt and decrypt with explicit ring settings" in: + "Enigma engine" should "encrypt and decrypt with explicit ring settings" in: val data = Table( ("plain", "cipher", "settings"), ("MARTIN", "WFSEXB", "III-II-I AAA AAZ B"), @@ -37,15 +39,28 @@ class EnigmaSpec ) forAll(data)((plain: String, cipher: String, settings: String) => given s: Settings = Settings(settings) - verify(plain, cipher) + Enigma.run(plain.bytes).string shouldBe cipher ) - "Settings" should "parse with ring settings" in: + "Settings" should "parse with pos settings" in: val settings = Settings("III-II-I AAA AAZ B") settings.rotors.rotors shouldBe Rotors("III-II-I AAA AAZ ").rotors settings.reflector shouldBe Reflector.B - def verify(text: String, cipher: String)(using settings: Settings): Any = + "Settings" should "parse without pos settings default to AAA" in: + val settings = Settings("III-II-I AAZ B") + settings.rotors.rotors shouldBe Rotors("III-II-I AAZ AAA ").rotors + settings.reflector shouldBe Reflector.B + + "Enigma" should "add random start position and encrypted message key to preamble" in: + val secret = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do" + val expected = secret.toUpperCase.filter(_.isLetter) + given settings:String = "III-II-I AAA B" + + val encrypted: Encrypted[Try, String] = secret.encrypted + encrypted.decrypted.success.value shouldBe expected + + def verify(text: String, cipher: String)(using settings: String): Any = val encrypted = text.encrypted encrypted.bytes.success.value.map(_.toChar).mkString shouldBe cipher encrypted.decrypted.success.value shouldBe text From c63f3e57eb2f2e4be880e865379b2618c15d9903 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 30 Nov 2025 21:29:52 +0100 Subject: [PATCH 04/13] Refactor Enigma core and supporting components: - Enhanced `Settings` to handle rotor position adjustments and improve parsing logic for varied input formats. - Updated `Enigma` encryption and decryption flow to include random positions and keys in messages, to follow German army practice. - Optimized `Glyph` with random glyph generation and added byte conversion extensions. - Refined `Rotors` to expose size, position updates, and enhance stepping mechanism with improved logging. - Adjusted test cases to validate new settings, encryption flow, and message preambles. --- .../src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala index 1de56e0..069ab42 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -59,8 +59,3 @@ class EnigmaSpec val encrypted: Encrypted[Try, String] = secret.encrypted encrypted.decrypted.success.value shouldBe expected - - def verify(text: String, cipher: String)(using settings: String): Any = - val encrypted = text.encrypted - encrypted.bytes.success.value.map(_.toChar).mkString shouldBe cipher - encrypted.decrypted.success.value shouldBe text From c1853870c4f354a2bcd770310298339e913e46cc Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 30 Nov 2025 23:12:01 +0100 Subject: [PATCH 05/13] Implement PlugBoard and enhance Enigma configuration: - Added `PlugBoard` implementation to support plugboard swapping in the Enigma machine. - Updated `Settings` to handle plugboard parsing and validation. - Refactored `Enigma` encryption/decryption logic to integrate plugboard swaps before and after rotor operations. - Expanded unit tests to validate plugboard behavior, settings parsing, and encryption alignment with plugboard configuration. --- .../scala/cryptic/cipher/enigma/Enigma.scala | 88 ++++++++++--------- .../scala/cryptic/cipher/enigma/Glyph.scala | 3 + .../cryptic/cipher/enigma/PlugBoard.scala | 66 ++++++++++++++ .../cryptic/cipher/enigma/Reflector.scala | 2 - .../scala/cryptic/cipher/enigma/Wheel.scala | 2 - .../cryptic/cipher/enigma/EnigmaSpec.scala | 50 ++++++++--- .../cryptic/cipher/enigma/GlyphSpec.scala | 4 +- .../cryptic/cipher/enigma/PlugBoardSpec.scala | 52 +++++++++++ .../cryptic/cipher/enigma/ReflectorSpec.scala | 4 +- .../cipher/enigma/RotorApplySpec.scala | 4 +- .../cryptic/cipher/enigma/RotorSpec.scala | 4 +- .../cipher/enigma/RotorsApplySpec.scala | 4 +- .../cryptic/cipher/enigma/RotorsSpec.scala | 4 +- .../cryptic/cipher/enigma/WheelSpec.scala | 4 +- 14 files changed, 226 insertions(+), 65 deletions(-) create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 30b50d6..3ccab2d 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -4,7 +4,7 @@ package enigma import scala.util.{Success, Try} -case class Settings(rotors: Rotors, reflector: Reflector): +case class Settings(rotors: Rotors, reflector: Reflector, plugboard: PlugBoard): /** * Adjusts the rotor positions using the given array of glyphs and returns an updated Settings object. * @@ -14,50 +14,57 @@ case class Settings(rotors: Rotors, reflector: Reflector): def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) object Settings: - /** Parses a settings string and constructs a `Settings` instance representing - * the rotor and reflector configurations for an Enigma machine. - * - * The input settings string must match one of the following formats: - * - `"names rings positions reflector"` (e.g., `"III-II-I AAA AAZ B"`) - * - `"names rings reflector"` (e.g., `"III-II-I AAA B"`), in which case - * positions will default to all 'A'. - * - * @param settings - * the input string specifying the rotor names, ring settings, (optional) - * initial positions, and reflector. Valid formats: - * `"names rings positions reflector"` or `"names rings reflector"`. - * - `names` are hyphen-separated rotor IDs (left-to-right order). - * - `rings` and `positions` are sequences of letters where each length - * matches the number of rotor names. - * - `reflector` is a single letter (e.g., `B`). - * - * Example: "III-II-I AAA AAZ B" or "III-II-I AAA B". - * @return - * a `Settings` object representing the parsed rotor and reflector - * configurations. - * @throws IllegalArgumentException - * if the input does not adhere to the expected format, if the lengths of - * `names`, `rings`, or `positions` are mismatched, or if any values are - * invalid (e.g., unknown rotor names, invalid reflector). - */ + /** + * Parses a settings string to construct a `Settings` object with the specified + * rotors, reflector, and plugboard configuration. + * + * The settings string must follow the format: + * "names rings [positions] reflector [plugboard]" + * - `names`: Hyphen-separated rotor identifiers from left-most to right-most, e.g., "III-II-I". + * - `rings`: A sequence of letters representing the ring settings, e.g., "AAA". + * - `positions` (optional): A sequence of letters representing the initial rotor positions, e.g., "AAZ". + * If omitted, defaults to "A" for each rotor. + * - `reflector`: A single letter indicating the reflector type, e.g., "B". + * - `plugboard` (optional): A string of paired letters representing plugboard connections, e.g., "ABCD". + * + * @param settings A string specifying the rotor names, ring settings, initial rotor positions, + * reflector type, and optional plugboard configuration. + * @return A `Settings` instance constructed based on the provided settings string. + * @throws IllegalArgumentException If the settings string does not match the required format or contains + * invalid values. + */ def apply(settings: String): Settings = // Match four parts: names, rings, positions, reflector + val WithPosPB = + """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s+([A-Za-z]+)\s*$""".r val WithPos = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r + val NoPosPB = + """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s+([A-Za-z]+)\s*$""".r val NoPos = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r settings match + case WithPosPB(namesPart, ringsPart, posPart, reflPart, plugPairs) => + val rotors = Rotors(s"$namesPart $ringsPart $posPart") + val reflector = Reflector.valueOf(reflPart.toUpperCase) + val plugboard = PlugBoard(plugPairs) + Settings(rotors, reflector, plugboard) case WithPos(namesPart, ringsPart, posPart, reflPart) => val rotors = Rotors(s"$namesPart $ringsPart $posPart") val reflector = Reflector.valueOf(reflPart.toUpperCase) - Settings(rotors, reflector) + Settings(rotors, reflector, PlugBoard("")) + case NoPosPB(namesPart, ringPart, reflPart, plugPairs) => + val rotors = Rotors(s"$namesPart $ringPart ${"A" * ringPart.length}") + val reflector = Reflector.valueOf(reflPart.toUpperCase) + val plugboard = PlugBoard(plugPairs) + Settings(rotors, reflector, plugboard) case NoPos(namesPart, ringPart, reflPart) => val rotors = Rotors(s"$namesPart $ringPart ${"A" * ringPart.length}") val reflector = Reflector.valueOf(reflPart.toUpperCase) - Settings(rotors, reflector) + Settings(rotors, reflector, PlugBoard("")) case _ => throw IllegalArgumentException( - "Settings.apply requires format \"names rings [positions] reflector\" (e.g. \"III-II-I AAA AAZ B\")" + """Settings requires format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" ) object Enigma: @@ -69,9 +76,8 @@ object Enigma: export cryptic.default.{given, *} export Enigma.{given, *} - given encrypt(using settings: String): Encrypt[Try] = + given encrypt(using base: Settings): Encrypt[Try] = (plaintext: PlainText) => - val base = Settings(settings) val n = base.rotors.size val start = Glyph.random(n) val key = Glyph.random(n) @@ -81,10 +87,9 @@ object Enigma: CipherText(start.iarray, encryptedKey.iarray, encryptedMessage.iarray) Success(cipher) - given decrypt(using settings: String): Decrypt[Try] = + given decrypt(using base: Settings): Decrypt[Try] = (_: CipherText).splitWith: case IArray(startBytes, encryptedKeyBytes, encryptedMessageBytes) => - val base = Settings(settings) val n = base.rotors.size val start = glyph(startBytes) val encryptedKey = glyph(encryptedKeyBytes) @@ -94,22 +99,19 @@ object Enigma: val message = run(encryptedMessage)(using base.pos(key)) Try(PlainText(message.string)) - def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run( - glyph(message) - ) - + def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run(message.glyph) def run(message: IArray[Glyph])(using settings: Settings): IArray[Glyph] = var rotors = settings.rotors message .map: g => // Rotate all rotors applying ripple-carry rules rotors = rotors.rotate - // Forward through rotors, reflect, then backward out - val (forward, inTrace) = rotors.in(g) + // Plugboard in, forward through rotors, reflect, then backward out + val swappedIn = settings.plugboard.swap(g) + val (forward, inTrace) = rotors.in(swappedIn) val reflected = settings.reflector.reflect(forward) val (output, outTrace) = rotors.out(reflected) + val swappedOut = settings.plugboard.swap(output) scribe.trace(s"encoded ${inTrace.string}-${outTrace.string}") - output + swappedOut - def glyph(bytes: IArray[Byte]): IArray[Glyph] = - new String(bytes.mutable).glyph diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala index 8111082..f37e3a8 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -74,3 +74,6 @@ extension (iter: Iterable[Glyph]) */ def string: String = iter.map(_.char).mkString def iarray: IArray[Byte] = IArray.from(iter.map(_.byte)) + +extension (iarray:IArray[Byte]) + def glyph:IArray[Glyph] = new String(iarray.mutable).glyph \ No newline at end of file diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala new file mode 100644 index 0000000..3502dd6 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala @@ -0,0 +1,66 @@ +package cryptic +package cipher +package enigma + +/** Simple Enigma plugboard implementation. + * + * @param wiring + * Zero to ten disjoint pairs of unique `Glyph`s. Each `Glyph` may appear at + * most once across all pairs, and the two `Glyph`s within a pair must differ. + */ +case class PlugBoard(wiring: IArray[(Glyph, Glyph)]) + : + // Validate constraints + require(wiring.length <= 10, "PlugBoard may contain at most 10 pairs") + require( + wiring.forall((a, b) => a != b), + "PlugBoard pairs must map two different letters (no self-pairs)" + ) + private val allGlyphs: IArray[Glyph] = IArray.from(wiring.flatMap((a, b) => Array(a, b))) + require( + allGlyphs.distinct.length == allGlyphs.length, + "PlugBoard letters must be unique across all pairs" + ) + + /** Swap the provided `Glyph` using the configured wiring. If the `Glyph` + * is not present in any pair, it is returned unchanged. + */ + def swap(g: Glyph): Glyph = + // simple linear search across pairs; wiring limited to 10 so this is fine + var i = 0 + while i < wiring.length do + val (a, b) = wiring(i) + if g == a then return b + if g == b then return a + i += 1 + g + +object PlugBoard: + /** Construct a PlugBoard from a concatenated string of letter pairs with no + * spaces, e.g. "ABCD" -> pairs (A,B), (C,D). Lowercase letters are + * accepted and normalized to uppercase. The total number of pairs must be + * between 0 and 10 (i.e. length 0 to 20), and letters must be unique. + */ + def apply(pairs: String): PlugBoard = + val s = pairs.trim + // Validate characters: only letters allowed + if s.nonEmpty && !s.forall(_.isLetter) then + throw IllegalArgumentException("PlugBoard.apply requires only letters A–Z or a–z") + // Normalize to Glyphs (which upper-cases and validates) + val glyphs: IArray[Glyph] = s.glyph + if glyphs.length != s.length then + // This would only happen if non-letters were present; be explicit + throw IllegalArgumentException("PlugBoard.apply contains invalid characters") + if glyphs.length % 2 != 0 then + throw IllegalArgumentException("PlugBoard.apply requires an even number of letters (pairs)") + val pairCount = glyphs.length / 2 + if pairCount > 10 then + throw IllegalArgumentException("PlugBoard may contain at most 10 pairs (20 letters)") + val arr = new Array[(Glyph, Glyph)](pairCount) + var i = 0 + while i < pairCount do + val a = glyphs(i * 2) + val b = glyphs(i * 2 + 1) + arr(i) = (a, b) + i += 1 + PlugBoard(IArray.from(arr)) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala index 6fc385e..cb91a68 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Reflector.scala @@ -2,8 +2,6 @@ package cryptic package cipher package enigma -import scala.annotation.targetName - /** Enigma reflectors A, B, C. * * Wiring is expressed in the domain type `Glyph` and the primary API operates diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala index b9f49be..26a18c8 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala @@ -2,8 +2,6 @@ package cryptic package cipher package enigma -import Glyph.* - import scala.util.Try case class Wheel(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala index 069ab42..c974893 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/EnigmaSpec.scala @@ -7,7 +7,7 @@ import org.scalatest.matchers.should.Matchers import org.scalatest.TryValues import org.scalatest.prop.TableDrivenPropertyChecks -import scala.util.{Success, Try} +import scala.util.Try class EnigmaSpec extends AnyFlatSpec @@ -16,13 +16,15 @@ class EnigmaSpec with TableDrivenPropertyChecks: behavior of "Enigma" - import Enigma.default.{given, *} - import Functor.tryFunctor + import Enigma.default.{*, given} val lorem: String = "lorem.txt".fromResource.glyph.string - val loremCipherAAA_AAA: String = "lorem-cipher-AAA-AAA.txt".fromResource.glyph.string - val loremCipherMAR_ADQ: String = "lorem-cipher-MAR_ADQ.txt".fromResource.glyph.string - val loremCipherZBQ_AAA: String = "lorem-cipher-ZBQ-AAA.txt".fromResource.glyph.string + val loremCipherAAA_AAA: String = + "lorem-cipher-AAA-AAA.txt".fromResource.glyph.string + val loremCipherMAR_ADQ: String = + "lorem-cipher-MAR_ADQ.txt".fromResource.glyph.string + val loremCipherZBQ_AAA: String = + "lorem-cipher-ZBQ-AAA.txt".fromResource.glyph.string "Enigma engine" should "encrypt and decrypt with explicit ring settings" in: val data = Table( @@ -31,6 +33,7 @@ class EnigmaSpec ("MARTIN", "SNJAJP", "III-II-I AAB AAZ B"), ("MARTIN", "HTFUSP", "III-II-I AAA AAA B"), ("MARTIN", "ITDVLW", "III-II-I AAE AAA B"), + ("MARTIN", "FDEQBM", "III-II-I AAA AAA B MARTIN"), ("ABCD", "ZUIN", "III-II-I AAA ADQ B"), ("ABCD", "NODJ", "III-II-I AAA LEQ B"), (lorem, loremCipherAAA_AAA, "III-II-I AAA AAA B"), @@ -52,10 +55,35 @@ class EnigmaSpec settings.rotors.rotors shouldBe Rotors("III-II-I AAZ AAA ").rotors settings.reflector shouldBe Reflector.B + "Settings" should "parse plugboard as last token when provided" in: + val settings = Settings("III-II-I AAA AAZ B ABCD") + settings.rotors.rotors shouldBe Rotors("III-II-I AAA AAZ ").rotors + settings.reflector shouldBe Reflector.B + // Plugboard ABCD => A<->B, C<->D + settings.plugboard.swap('A'.glyph).char shouldBe 'B' + settings.plugboard.swap('B'.glyph).char shouldBe 'A' + settings.plugboard.swap('C'.glyph).char shouldBe 'D' + settings.plugboard.swap('E'.glyph).char shouldBe 'E' + "Enigma" should "add random start position and encrypted message key to preamble" in: - val secret = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do" - val expected = secret.toUpperCase.filter(_.isLetter) - given settings:String = "III-II-I AAA B" + val plain = + "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do".glyph.string + given settings: Settings = Settings("III-II-I AAA B") + val encrypted: Encrypted[Try, String] = plain.encrypted + encrypted.decrypted.success.value shouldBe plain + + "Enigma" should "apply plugboard swap before and after rotors" in: + val base = Settings("III-II-I AAA AAZ B") + val withPB = Settings("III-II-I AAA AAZ B QW") // Q<->W pair + val plug = withPB.plugboard + val msg = "HELLOWORLD" + + // Encrypt with plugboard + val outWithPB = Enigma.run(msg.glyph)(using withPB) + + // Encrypt without plugboard but manually swap before and after + val preSwapped = msg.glyph.map(plug.swap) + val core = Enigma.run(preSwapped)(using base) + val postSwapped = core.map(plug.swap) - val encrypted: Encrypted[Try, String] = secret.encrypted - encrypted.decrypted.success.value shouldBe expected + outWithPB shouldBe postSwapped diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala index e4c0866..0f3f9fd 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/GlyphSpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala new file mode 100644 index 0000000..d456ef2 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala @@ -0,0 +1,52 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class PlugBoardSpec extends AnyFlatSpec with Matchers: + behavior of "PlugBoard" + + it should "swap pairs and be identity for others" in: + val pb = PlugBoard("ABCD") // A<->B, C<->D + pb.swap('A'.glyph).char shouldBe 'B' + pb.swap('B'.glyph).char shouldBe 'A' + pb.swap('C'.glyph).char shouldBe 'D' + pb.swap('D'.glyph).char shouldBe 'C' + // Unplugged letters unchanged + pb.swap('E'.glyph).char shouldBe 'E' + + it should "be symmetric (swap twice yields original)" in: + val pb = PlugBoard("QWERTY") // Q<->W, E<->R, T<->Y + ('A' to 'Z').foreach: c => + val g = c.glyph + pb.swap(pb.swap(g)) shouldBe g + + it should "accept empty wiring and act as identity" in: + val pb = PlugBoard("") + ('A' to 'Z').foreach: c => + pb.swap(c.glyph).char shouldBe c + + it should "parse lowercase and uppercase letters equivalently" in: + val up = PlugBoard("ABCD") + val lo = PlugBoard("abCd") + ('A' to 'Z').foreach: c => + up.swap(c.glyph) shouldBe lo.swap(c.glyph) + + it should "reject odd number of letters" in: + an[IllegalArgumentException] should be thrownBy PlugBoard("ABC") + + it should "reject duplicate letters across pairs" in: + // 'A' duplicated + an[IllegalArgumentException] should be thrownBy PlugBoard("ABAC") + + it should "reject self-pairs like AA" in: + an[IllegalArgumentException] should be thrownBy PlugBoard("AABC") + + it should "reject more than 10 pairs" in: + val twentyTwo = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".take(22) // 11 pairs + an[IllegalArgumentException] should be thrownBy PlugBoard(twentyTwo) + + it should "reject non-letter characters" in: + an[IllegalArgumentException] should be thrownBy PlugBoard("AB- CD") diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala index ebf7a62..2d32adf 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/ReflectorSpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala index 213d976..e60cbac 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorApplySpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala index 0a509c0..21e49df 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala index 441e02c..5930e13 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsApplySpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala index f12accc..d1f4a33 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala index 2035d06..c651799 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/WheelSpec.scala @@ -1,4 +1,6 @@ -package cryptic.cipher.enigma +package cryptic +package cipher +package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers From 6233c0c97268a4f602c4e01afc7c3d185a4087e7 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 30 Nov 2025 23:36:52 +0100 Subject: [PATCH 06/13] Refactor Enigma `Settings` and update encryption/decryption logic: - Consolidated regex patterns for settings parsing into a single format. - Enhanced error handling in decryption with validation for inconsistent keys. - Adjusted encryption/decryption constructors to replace `base` parameter with `settings`. - Improved Scaladoc formatting for methods, ensuring clarity and adherence to style guidelines. --- .../scala/cryptic/cipher/enigma/Enigma.scala | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 3ccab2d..46a7d17 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -2,66 +2,58 @@ package cryptic package cipher package enigma -import scala.util.{Success, Try} +import scala.util.{Failure, Success, Try} case class Settings(rotors: Rotors, reflector: Reflector, plugboard: PlugBoard): - /** - * Adjusts the rotor positions using the given array of glyphs and returns an updated Settings object. - * - * @param pos An immutable array of Glyph objects representing the rotor positions to be set. - * @return A new Settings instance with the updated rotor positions. - */ + /** Adjusts the rotor positions using the given array of glyphs and returns an + * updated Settings object. + * + * @param pos + * An immutable array of Glyph objects representing the rotor positions to + * be set. + * @return + * A new Settings instance with the updated rotor positions. + */ def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) object Settings: - /** - * Parses a settings string to construct a `Settings` object with the specified - * rotors, reflector, and plugboard configuration. - * - * The settings string must follow the format: - * "names rings [positions] reflector [plugboard]" - * - `names`: Hyphen-separated rotor identifiers from left-most to right-most, e.g., "III-II-I". - * - `rings`: A sequence of letters representing the ring settings, e.g., "AAA". - * - `positions` (optional): A sequence of letters representing the initial rotor positions, e.g., "AAZ". - * If omitted, defaults to "A" for each rotor. - * - `reflector`: A single letter indicating the reflector type, e.g., "B". - * - `plugboard` (optional): A string of paired letters representing plugboard connections, e.g., "ABCD". - * - * @param settings A string specifying the rotor names, ring settings, initial rotor positions, - * reflector type, and optional plugboard configuration. - * @return A `Settings` instance constructed based on the provided settings string. - * @throws IllegalArgumentException If the settings string does not match the required format or contains - * invalid values. - */ + /** Parses a settings string to construct a `Settings` object with the + * specified rotors, reflector, and plugboard configuration. + * + * The settings string must follow the format: "names rings [positions] + * reflector [plugboard]" + * - `names`: Hyphen-separated rotor identifiers from left-most to + * right-most, e.g., "III-II-I". + * - `rings`: A sequence of letters representing the ring settings, e.g., + * "AAA". + * - `positions` (optional): A sequence of letters representing the initial + * rotor positions, e.g., "AAZ". If omitted, defaults to "A" for each + * rotor. + * - `reflector`: A single letter indicating the reflector type, e.g., "B". + * - `plugboard` (optional): A string of paired letters representing + * plugboard connections, e.g., "ABCD". + * + * @param settings + * A string specifying the rotor names, ring settings, initial rotor + * positions, reflector type, and optional plugboard configuration. + * @return + * A `Settings` instance constructed based on the provided settings string. + * @throws IllegalArgumentException + * If the settings string does not match the required format or contains + * invalid values. + */ def apply(settings: String): Settings = - // Match four parts: names, rings, positions, reflector - val WithPosPB = - """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s+([A-Za-z]+)\s*$""".r - val WithPos = - """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r - val NoPosPB = - """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s+([A-Za-z]+)\s*$""".r - val NoPos = """^\s*([^\s]+)\s+([A-Za-z]+)\s+([A-Ca-c])\s*$""".r + // Match parts: names, rings, optional positions, reflector, optional plugboard + val SettingsFormat = + """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])\s*([A-Za-z]*)\s*$""".r settings match - case WithPosPB(namesPart, ringsPart, posPart, reflPart, plugPairs) => - val rotors = Rotors(s"$namesPart $ringsPart $posPart") - val reflector = Reflector.valueOf(reflPart.toUpperCase) - val plugboard = PlugBoard(plugPairs) + case SettingsFormat(names, rings, posOrNull, refl, pb) => + val pos = Option(posOrNull).getOrElse("A" * rings.length) + val rotors = Rotors(s"$names $rings $pos") + val reflector = Reflector.valueOf(refl.toUpperCase) + val plugboard = PlugBoard(pb) Settings(rotors, reflector, plugboard) - case WithPos(namesPart, ringsPart, posPart, reflPart) => - val rotors = Rotors(s"$namesPart $ringsPart $posPart") - val reflector = Reflector.valueOf(reflPart.toUpperCase) - Settings(rotors, reflector, PlugBoard("")) - case NoPosPB(namesPart, ringPart, reflPart, plugPairs) => - val rotors = Rotors(s"$namesPart $ringPart ${"A" * ringPart.length}") - val reflector = Reflector.valueOf(reflPart.toUpperCase) - val plugboard = PlugBoard(plugPairs) - Settings(rotors, reflector, plugboard) - case NoPos(namesPart, ringPart, reflPart) => - val rotors = Rotors(s"$namesPart $ringPart ${"A" * ringPart.length}") - val reflector = Reflector.valueOf(reflPart.toUpperCase) - Settings(rotors, reflector, PlugBoard("")) case _ => throw IllegalArgumentException( """Settings requires format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" @@ -76,42 +68,51 @@ object Enigma: export cryptic.default.{given, *} export Enigma.{given, *} - given encrypt(using base: Settings): Encrypt[Try] = + given encrypt(using settings: Settings): Encrypt[Try] = (plaintext: PlainText) => - val n = base.rotors.size + val n = settings.rotors.size val start = Glyph.random(n) val key = Glyph.random(n) - val encryptedKey = run(key ++ key)(using base.pos(start)) - val encryptedMessage = run(plaintext.bytes)(using base.pos(key)) + val encryptedKey = run(key ++ key)(using settings.pos(start)) + val encryptedMessage = run(plaintext.bytes)(using settings.pos(key)) val cipher = CipherText(start.iarray, encryptedKey.iarray, encryptedMessage.iarray) Success(cipher) - given decrypt(using base: Settings): Decrypt[Try] = + given decrypt(using settings: Settings): Decrypt[Try] = (_: CipherText).splitWith: case IArray(startBytes, encryptedKeyBytes, encryptedMessageBytes) => - val n = base.rotors.size + val n = settings.rotors.size val start = glyph(startBytes) val encryptedKey = glyph(encryptedKeyBytes) val encryptedMessage = glyph(encryptedMessageBytes) - val doubleKey = run(encryptedKey)(using base.pos(start)) - val key = doubleKey.take(n) - val message = run(encryptedMessage)(using base.pos(key)) - Try(PlainText(message.string)) + val doubleKey = run(encryptedKey)(using settings.pos(start)) + val (key1, key2) = doubleKey.splitAt(n) + if key1 == key2 + then + Failure( + IllegalArgumentException( + s"Inconsistent key in preamble ${start.string} ${doubleKey.string}" + ) + ) + else + val message = run(encryptedMessage)(using settings.pos(key1)) + Success(PlainText(message.string)) - def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run(message.glyph) + def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run( + message.glyph + ) def run(message: IArray[Glyph])(using settings: Settings): IArray[Glyph] = var rotors = settings.rotors message .map: g => - // Rotate all rotors applying ripple-carry rules rotors = rotors.rotate - // Plugboard in, forward through rotors, reflect, then backward out val swappedIn = settings.plugboard.swap(g) val (forward, inTrace) = rotors.in(swappedIn) val reflected = settings.reflector.reflect(forward) val (output, outTrace) = rotors.out(reflected) val swappedOut = settings.plugboard.swap(output) - scribe.trace(s"encoded ${inTrace.string}-${outTrace.string}") + scribe.trace( + s"encoded ${g.char} ${inTrace.string}-${outTrace.string} ${swappedOut.char}" + ) swappedOut - From 0d61f069601ce7a0991c25e8f1ffe99c82afc836 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Mon, 1 Dec 2025 23:10:55 +0100 Subject: [PATCH 07/13] Add CLI support, fat-jar assembly, and refactor Enigma `Settings` - Added CLI entry point for the Enigma module using `mainargs` for better command-line handling. - Integrated `sbt-assembly` to enable building executable fat jars with proper merging strategies. - Updated `Settings` to support preamble handling and extracted it into a standalone class. - Enhanced encryption/decryption logic and documentation for improved usability and clarity. --- build.sbt | 44 +++- .../scala/cryptic/cipher/enigma/Enigma.scala | 206 +++++++++++++----- .../cryptic/cipher/enigma/Settings.scala | 65 ++++++ core/src/main/scala/cryptic/Cryptic.scala | 3 + project/plugins.sbt | 2 + 5 files changed, 264 insertions(+), 56 deletions(-) create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala diff --git a/build.sbt b/build.sbt index 217cdb6..a2ed946 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,7 @@ import scala.collection.Seq +// sbt-assembly keys and helpers +import sbtassembly.AssemblyPlugin.autoImport.* +import sbtassembly.{MergeStrategy, PathList} lazy val scalaTest = ("org.scalatest" %% "scalatest" % "3.2.19").withSources() lazy val chill = ("com.twitter" % "chill" % "0.10.0") @@ -172,7 +175,43 @@ lazy val `cipher-bouncycastle` = (project in file("cipher-bouncycastle")) lazy val `cipher-enigma` = (project in file("cipher-enigma")) .settings( cipherCommonSettings("cipher-enigma") ++ Seq( - libraryDependencies += scribe + libraryDependencies ++= Seq( + scribe, + "com.lihaoyi" %% "mainargs" % "0.7.6" + ), + // Make the packaged jar executable by setting the Main-Class + Compile / packageBin / packageOptions += sbt.Package.MainClass( + "cryptic.cipher.enigma.Enigma" + ), + // --- Assembly (fat jar) configuration --- + // Ensure the assembly has the correct entry point + assembly / mainClass := Some("cryptic.cipher.enigma.Enigma"), + // Name of the assembled jar produced by the subproject (we still copy/rename below) + assembly / assemblyJarName := s"${name.value}-fat-${version.value}.jar", + // Include scala-library and all dependencies inside the fat jar + assembly / assemblyOption := (assembly / assemblyOption).value + .withIncludeScala(true) + .withIncludeDependency(true), + // Merge strategy to avoid META-INF clashes and concatenate reference.conf when present + assembly / assemblyMergeStrategy := { + case PathList("reference.conf") => MergeStrategy.concat + case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard + case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard + case PathList("META-INF", xs*) => MergeStrategy.discard + case PathList("module-info.class") => MergeStrategy.discard + case x if x.endsWith(".proto") => MergeStrategy.first + case x if x.endsWith("io.netty.versions.properties") => MergeStrategy.first + case _ => MergeStrategy.first + }, + // Project-scoped task to produce a stable-named fat jar at cipher-enigma/target/enigma.jar + enigma := { + val log = streams.value.log + val fat = (Compile / assembly).value + val out = (ThisProject / target).value / "enigma.jar" + IO.copyFile(fat, out) + log.info(s"Wrote ${out.getAbsolutePath}") + out + } ) ) .dependsOn(core) @@ -209,3 +248,6 @@ lazy val cryptic = (project in file(".")) `cipher-enigma`, `cipher-test` ) + +// Task key to build an executable Enigma fat jar (scoped per project) +lazy val enigma = taskKey[File]("Create an executable Enigma CLI fat jar named 'enigma.jar'") diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 46a7d17..651a8a8 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -3,61 +3,7 @@ package cipher package enigma import scala.util.{Failure, Success, Try} - -case class Settings(rotors: Rotors, reflector: Reflector, plugboard: PlugBoard): - /** Adjusts the rotor positions using the given array of glyphs and returns an - * updated Settings object. - * - * @param pos - * An immutable array of Glyph objects representing the rotor positions to - * be set. - * @return - * A new Settings instance with the updated rotor positions. - */ - def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) - -object Settings: - /** Parses a settings string to construct a `Settings` object with the - * specified rotors, reflector, and plugboard configuration. - * - * The settings string must follow the format: "names rings [positions] - * reflector [plugboard]" - * - `names`: Hyphen-separated rotor identifiers from left-most to - * right-most, e.g., "III-II-I". - * - `rings`: A sequence of letters representing the ring settings, e.g., - * "AAA". - * - `positions` (optional): A sequence of letters representing the initial - * rotor positions, e.g., "AAZ". If omitted, defaults to "A" for each - * rotor. - * - `reflector`: A single letter indicating the reflector type, e.g., "B". - * - `plugboard` (optional): A string of paired letters representing - * plugboard connections, e.g., "ABCD". - * - * @param settings - * A string specifying the rotor names, ring settings, initial rotor - * positions, reflector type, and optional plugboard configuration. - * @return - * A `Settings` instance constructed based on the provided settings string. - * @throws IllegalArgumentException - * If the settings string does not match the required format or contains - * invalid values. - */ - def apply(settings: String): Settings = - // Match parts: names, rings, optional positions, reflector, optional plugboard - val SettingsFormat = - """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])\s*([A-Za-z]*)\s*$""".r - - settings match - case SettingsFormat(names, rings, posOrNull, refl, pb) => - val pos = Option(posOrNull).getOrElse("A" * rings.length) - val rotors = Rotors(s"$names $rings $pos") - val reflector = Reflector.valueOf(refl.toUpperCase) - val plugboard = PlugBoard(pb) - Settings(rotors, reflector, plugboard) - case _ => - throw IllegalArgumentException( - """Settings requires format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" - ) +import scala.io.Source object Enigma: @@ -68,6 +14,22 @@ object Enigma: export cryptic.default.{given, *} export Enigma.{given, *} + /** Encrypts a given plaintext into its cipher text representation based on + * the provided settings. + * + * The method applies a series of operations including randomization of the + * rotor start positions and key, running the encryption process on the + * plaintext bytes, and constructing a `CipherText` object that represents + * the encrypted message. + * + * @param settings + * The encryption settings, including rotor configuration and positions, + * that define the behavior of the encryption process. + * @return + * An encryption `Encrypt` instance which uses `Try` as its effect type, + * producing a `Success` containing the cipher text upon successful + * encryption, or a `Failure` in case of unexpected issues. + */ given encrypt(using settings: Settings): Encrypt[Try] = (plaintext: PlainText) => val n = settings.rotors.size @@ -79,6 +41,25 @@ object Enigma: CipherText(start.iarray, encryptedKey.iarray, encryptedMessage.iarray) Success(cipher) + /** Decrypts a given ciphertext into its plaintext representation using the + * specified settings. + * + * This method extracts components from the ciphertext, including the rotor + * start positions, encrypted key, and the encrypted message. It validates + * the consistency of the key, and if valid, decrypts the message using the + * rotor positions derived from the initial key. The result is returned as a + * success or failure, depending on whether the decryption process completes + * successfully. + * + * @param settings + * The decryption settings, including rotor configuration, reflector, and + * plugboard, which define the operations required for decryption. Note + * that the start positions are taken from the preamble + * @return + * A `Decrypt` instance for the `Try` effect type, producing a `Success` + * containing the decrypted plaintext if the decryption process succeeds, + * or a `Failure` in case of any inconsistencies or errors. + */ given decrypt(using settings: Settings): Decrypt[Try] = (_: CipherText).splitWith: case IArray(startBytes, encryptedKeyBytes, encryptedMessageBytes) => @@ -102,6 +83,30 @@ object Enigma: def run(message: IArray[Byte])(using Settings): IArray[Glyph] = run( message.glyph ) + + /** Processes an array of input glyphs and transforms each glyph based on the + * provided settings, including rotor configurations, plugboard mappings, and + * reflector behavior. The method simulates the operation of an Enigma-like + * encryption machine. + * + * The transformation involves several steps for each input glyph: + * - Rotating rotors to their next positions. + * - Applying plugboard swaps to the glyph. + * - Passing the glyph through the rotors in forward direction. + * - Reflecting the glyph using the reflector. + * - Passing the glyph through the rotors in reverse direction. + * - Applying plugboard swaps to the glyph again. + * + * @param message + * The input array of glyphs to be processed by the encryption mechanism. + * @param settings + * The encryption or transformation settings, including rotor + * configurations, reflector, and plugboard mappings, provided as an + * implicit parameter. + * @return + * An array of glyphs resulting from the transformation of the input glyphs + * based on the specified settings. + */ def run(message: IArray[Glyph])(using settings: Settings): IArray[Glyph] = var rotors = settings.rotors message @@ -116,3 +121,94 @@ object Enigma: s"encoded ${g.char} ${inTrace.string}-${outTrace.string} ${swappedOut.char}" ) swappedOut + + // ---- CLI ---- + private def usage(): String = + """ + |Usage: Enigma [-d] "settings" [TEXT | -f FILE] + | + | -d Decrypt input (encryption is default) + | settings One string: "names rings [positions] reflector [plugpairs]" + | Example: "III-II-I AAA AAZ B ABCD" (A<->B, C<->D) + | If no positions are given a preamble will be generated when + | encrypting and is expected in the message when decrypting + | TEXT Optional second argument containing the message text + | -f FILE Read message text from FILE instead of TEXT or stdin + | + |Input text is normalized to letters A–Z (non-letters are ignored, lowercase allowed). + |Encrypt output is grouped into five-letter groups. Decrypt output is a continuous string. + |""".stripMargin + + private def readAllStdin(): String = Source.stdin.slurp + + private def readFile(path: String): String = Source.fromFile(path).slurp + + private def group5(s: String): String = s.grouped(5).mkString(" ") + + import mainargs.{ParserForMethods, Flag, arg, main as m} + + @m + def run( + @arg( + name = "decrypt", + short = 'd', + doc = "Decrypt input (default is encrypt)" + ) decrypt: Flag, + @arg( + name = "settings", + doc = "\"names rings [positions] reflector [plug-pairs]\"" + ) + settingsStr: String, + @arg(name = "f", short = 'f', doc = "Read message text from FILE") + file: Option[String], + @arg(doc = "Optional message TEXT (otherwise stdin)") + text: String* + ): Unit = + val encryptMode = !decrypt.value + + val input: String = + file + .map(readFile) + .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) + .getOrElse(readAllStdin()) + + import default.given + import Functor.tryFunctor + + given settings: Settings = Settings(settingsStr) + + def encodePreamble = + val enc = input.encrypted + val (start, key, message) = enc + .splitWith: + case IArray(start, key, message) => + Success( + (glyph(start).string, glyph(key).string, glyph(message).string) + ) + .get + s"$start $key ${group5(message)}" + + def decodePreamble = + val arr = input.split(" ") + val start = arr(0).glyph + val key = arr(1).glyph + val message = arr.drop(2).mkString.glyph + val enc = Encrypted[Try, String]( + Success(CipherText(start.iarray, key.iarray, message.iarray)) + ) + enc.decrypted.get + + def encode = group5(run(input.glyph).string) + + def decode = run(input.glyph).string + + val result = (encryptMode, settings.preamble) match + case (true, true) => encodePreamble + case (true, false) => encode + case (false, true) => decodePreamble + case (false, false) => decode + + println(result) + + def main(args: Array[String]): Unit = + ParserForMethods(Enigma).runOrExit(args) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala new file mode 100644 index 0000000..49a70b2 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala @@ -0,0 +1,65 @@ +package cryptic +package cipher +package enigma + +case class Settings( + rotors: Rotors, + reflector: Reflector, + plugboard: PlugBoard, + preamble: Boolean +): + /** Adjusts the rotor positions using the given array of glyphs and returns an + * updated Settings object. + * + * @param pos + * An immutable array of Glyph objects representing the rotor positions to + * be set. + * @return + * A new Settings instance with the updated rotor positions. + */ + def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) + +object Settings: + /** Parses a settings string to construct a `Settings` object with the + * specified rotors, reflector, and plugboard configuration. + * + * The settings string must follow the format: "names rings [positions] + * reflector [plugboard]" + * - `names`: Hyphen-separated rotor identifiers from left-most to + * right-most, e.g., "III-II-I". + * - `rings`: A sequence of letters representing the ring settings, e.g., + * "AAA". + * - `positions` (optional): A sequence of letters representing the initial + * rotor positions, e.g., "AAZ". If omitted, defaults to "A" for each + * rotor. + * - `reflector`: A single letter indicating the reflector type, e.g., "B". + * - `plugboard` (optional): A string of paired letters representing + * plugboard connections, e.g., "ABCD". + * + * @param settings + * A string specifying the rotor names, ring settings, initial rotor + * positions, reflector type, and optional plugboard configuration. + * @return + * A `Settings` instance constructed based on the provided settings string. + * @throws IllegalArgumentException + * If the settings string does not match the required format or contains + * invalid values. + */ + def apply(settings: String): Settings = + // Match parts: names, rings, optional positions, reflector, optional plugboard + val SettingsFormat = + """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])\s*([A-Za-z]*)\s*$""".r + + settings match + case SettingsFormat(names, rings, posOrNull, refl, pb) => + val posOption = Option(posOrNull) + val preamble = posOption.isEmpty + val pos = posOption.getOrElse("A" * rings.length) + val rotors = Rotors(s"$names $rings $pos") + val reflector = Reflector.valueOf(refl.toUpperCase) + val plugboard = PlugBoard(pb) + Settings(rotors, reflector, plugboard, preamble) + case _ => + throw IllegalArgumentException( + """Settings requires format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" + ) diff --git a/core/src/main/scala/cryptic/Cryptic.scala b/core/src/main/scala/cryptic/Cryptic.scala index fff6237..231d87b 100644 --- a/core/src/main/scala/cryptic/Cryptic.scala +++ b/core/src/main/scala/cryptic/Cryptic.scala @@ -504,6 +504,9 @@ object Encrypted: yield encrypted new Encrypted(cipherText) + def apply[F[_]: Functor, V: Codec](bytes: Array[Byte]): Encrypted[F, V] = + Encrypted[F, V](CipherText(bytes.immutable).pure) + /** Constructs an `Encrypted` value from a precomputed cipher text effect. * * Use this when you already have `F[CipherText]` and want to wrap it as diff --git a/project/plugins.sbt b/project/plugins.sbt index b2cf64b..f3f8906 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,3 +4,5 @@ addSbtPlugin("com.lightbend.paradox" % "sbt-paradox" % "0.10.7") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5") addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2") addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.28.0") +// Fat-jar support +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0") From c007ac8786ccda40534d6385cfa5884bb326f7f0 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Tue, 2 Dec 2025 20:57:59 +0100 Subject: [PATCH 08/13] Refactor Enigma CLI and improve assembly: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved CLI logic to a dedicated `CLI` object for cleaner architecture and separation of concerns. - Updated `build.sbt` to replace the entry point with the new `CLI` object and implement a self-contained bash script launcher embedding the fat jar. - Enhanced error handling and usage documentation for the `Settings` class, introducing a parsed `Try[Settings]` for better validation flow. - Refined `Settings` parsing with improved exception messaging and preamble formatting.‍‍ --- build.sbt | 67 ++++++--- .../scala/cryptic/cipher/enigma/CLI.scala | 127 ++++++++++++++++++ .../scala/cryptic/cipher/enigma/Enigma.scala | 91 ------------- .../cryptic/cipher/enigma/Settings.scala | 59 ++++---- 4 files changed, 208 insertions(+), 136 deletions(-) create mode 100644 cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala diff --git a/build.sbt b/build.sbt index a2ed946..82418ca 100644 --- a/build.sbt +++ b/build.sbt @@ -185,7 +185,7 @@ lazy val `cipher-enigma` = (project in file("cipher-enigma")) ), // --- Assembly (fat jar) configuration --- // Ensure the assembly has the correct entry point - assembly / mainClass := Some("cryptic.cipher.enigma.Enigma"), + assembly / mainClass := Some("cryptic.cipher.enigma.CLI"), // Name of the assembled jar produced by the subproject (we still copy/rename below) assembly / assemblyJarName := s"${name.value}-fat-${version.value}.jar", // Include scala-library and all dependencies inside the fat jar @@ -194,23 +194,56 @@ lazy val `cipher-enigma` = (project in file("cipher-enigma")) .withIncludeDependency(true), // Merge strategy to avoid META-INF clashes and concatenate reference.conf when present assembly / assemblyMergeStrategy := { - case PathList("reference.conf") => MergeStrategy.concat - case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard - case PathList("META-INF", xs*) => MergeStrategy.discard - case PathList("module-info.class") => MergeStrategy.discard - case x if x.endsWith(".proto") => MergeStrategy.first - case x if x.endsWith("io.netty.versions.properties") => MergeStrategy.first - case _ => MergeStrategy.first + case PathList("reference.conf") => MergeStrategy.concat + case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard + case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard + case PathList("META-INF", _*) => MergeStrategy.discard + case PathList("module-info.class") => MergeStrategy.discard + case x if x.endsWith(".proto") => MergeStrategy.first + case x if x.endsWith("io.netty.versions.properties") => + MergeStrategy.first + case _ => MergeStrategy.first }, - // Project-scoped task to produce a stable-named fat jar at cipher-enigma/target/enigma.jar + // Project-scoped task to produce a self-contained bash launcher script with the fat jar embedded enigma := { + import java.util.Base64 val log = streams.value.log val fat = (Compile / assembly).value - val out = (ThisProject / target).value / "enigma.jar" - IO.copyFile(fat, out) - log.info(s"Wrote ${out.getAbsolutePath}") - out + val targetDir = (ThisProject / target).value + val jarOut = targetDir / "enigma.jar" + // Keep writing the jar for convenience + IO.copyFile(fat, jarOut) + log.info(s"Wrote jar ${jarOut.getAbsolutePath}") + + val scriptOut = targetDir / "enigma" + val jarBytes = IO.readBytes(fat) + val b64 = Base64 + .getMimeEncoder(76, "\n".getBytes("UTF-8")) + .encodeToString(jarBytes) + + val header = + """#!/usr/bin/env bash +set -euo pipefail + +# Self-extract embedded Enigma jar and run it +TMPDIR_="${TMPDIR:-/tmp}" +JAR_="$(mktemp "${TMPDIR_%/}/enigma-jar-XXXXXX")" +cleanup_() { rm -f "$JAR_"; } +trap cleanup_ EXIT + +# Extract base64 jar payload that starts after the marker line +awk 'found{print} /^__JAR_BASE64__$/ {found=1; next}' "$0" | base64 --decode > "$JAR_" + +exec java ${JAVA_OPTS:-} -jar "$JAR_" "$@" +cleanup_ +__JAR_BASE64__ +""" + + IO.write(scriptOut, header + b64 + "\n") + // Make the script executable + scriptOut.setExecutable(true) + log.info(s"Wrote launcher ${scriptOut.getAbsolutePath}") + scriptOut } ) ) @@ -249,5 +282,7 @@ lazy val cryptic = (project in file(".")) `cipher-test` ) -// Task key to build an executable Enigma fat jar (scoped per project) -lazy val enigma = taskKey[File]("Create an executable Enigma CLI fat jar named 'enigma.jar'") +// Task key to build an executable Enigma bash script with embedded fat jar (scoped per project) +lazy val enigma = taskKey[File]( + "Create an executable 'enigma' bash script with embedded fat jar" +) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala new file mode 100644 index 0000000..206d4d2 --- /dev/null +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala @@ -0,0 +1,127 @@ +package cryptic +package cipher +package enigma + +import scala.io.Source +import scala.util.{Failure, Success, Try} + +object CLI: + private def usage(): String = + """ + |Usage: Enigma [-d] [-s SETTINGS] [TEXT | -f FILE] + | + | -d Decrypt input (encryption is default) + | -s, --settings SETTINGS + | One string: "names rings [positions] reflector [plugpairs]" + | Optional if ENIGMA_SETTINGS is set in the environment. The flag overrides. + | Example: "III-II-I AAA AAZ B ABCD" + | If no positions are given a preamble will be generated when + | encrypting and is expected in the message when decrypting + | TEXT Optional second argument containing the message text + | -f FILE Read message text from FILE instead of TEXT or stdin + | + |Input text is normalized to letters A–Z (non-letters are ignored, lowercase allowed). + |Encrypt output is grouped into five-letter groups. Decrypt output is a continuous string. + |""".stripMargin + + private def readAllStdin(): String = Source.stdin.slurp + + private def readFile(path: String): String = Source.fromFile(path).slurp + + private def group5(s: String): String = s.grouped(5).mkString(" ") + + import mainargs.{ParserForMethods, Flag, arg, main as m} + + @m(name = "enigma") + def run( + @arg( + name = "decrypt", + short = 'd', + doc = "Decrypt input (default is encrypt)" + ) decrypt: Flag, + @arg( + name = "settings", + short = 's', + doc = + "\"names rings [positions] reflector [plug-pairs]\" (can also be provided via ENIGMA_SETTINGS)" + ) + settingsStr: Option[String], + @arg(name = "f", short = 'f', doc = "Read message text from FILE") + file: Option[String], + @arg(doc = "Optional message TEXT (otherwise stdin)") + text: String* + ): Unit = + + val encryptMode = !decrypt.value + + import Enigma.default.{given, *} + import Functor.tryFunctor + + def settings: Try[Settings] = + settingsStr + .orElse(sys.env.get("ENIGMA_SETTINGS")) + .fold[Try[Settings]]( + Failure( + new IllegalArgumentException( + "Missing settings: use --settings or set ENIGMA_SETTINGS" + ) + ) + )(Settings.parse) + + def input: Try[String] = + Try: + file + .map(readFile) + .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) + .getOrElse(readAllStdin()) + + def encodePreamble(input: String)(using Settings): Try[String] = + input.encrypted + .splitWith: + case IArray(start, key, message) => + Success( + (glyph(start).string, glyph(key).string, glyph(message).string) + ) + .map: + case (start, key, message) => + s"$start $key ${group5(message)}" + + def decodePreamble(input: String)(using Settings): Try[String] = + val arr = input.split(" ") + val start = arr(0).glyph + val key = arr(1).glyph + val message = arr.drop(2).mkString.glyph + val enc = Encrypted[Try, String]( + Success(CipherText(start.iarray, key.iarray, message.iarray)) + ) + enc.decrypted + + def encode(input: String)(using Settings): Try[String] = + Try(group5(Enigma.run(input.glyph).string)) + + def decode(input: String)(using Settings): Try[String] = + Try(Enigma.run(input.glyph).string) + + def selectOperation(input: String)(using settings: Settings): Try[String] = + val op = (encryptMode, settings.preamble) match + case (true, true) => encodePreamble + case (true, false) => encode + case (false, true) => decodePreamble + case (false, false) => decode + op(input) + + val result = for + s <- settings + i <- input + result <- selectOperation(i)(using s) + yield result + result.fold( + throwable => + Console.err.println(throwable.getMessage) + Console.err.println(usage()) + sys.exit(1) + , + println + ) + + def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 651a8a8..70b0298 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -121,94 +121,3 @@ object Enigma: s"encoded ${g.char} ${inTrace.string}-${outTrace.string} ${swappedOut.char}" ) swappedOut - - // ---- CLI ---- - private def usage(): String = - """ - |Usage: Enigma [-d] "settings" [TEXT | -f FILE] - | - | -d Decrypt input (encryption is default) - | settings One string: "names rings [positions] reflector [plugpairs]" - | Example: "III-II-I AAA AAZ B ABCD" (A<->B, C<->D) - | If no positions are given a preamble will be generated when - | encrypting and is expected in the message when decrypting - | TEXT Optional second argument containing the message text - | -f FILE Read message text from FILE instead of TEXT or stdin - | - |Input text is normalized to letters A–Z (non-letters are ignored, lowercase allowed). - |Encrypt output is grouped into five-letter groups. Decrypt output is a continuous string. - |""".stripMargin - - private def readAllStdin(): String = Source.stdin.slurp - - private def readFile(path: String): String = Source.fromFile(path).slurp - - private def group5(s: String): String = s.grouped(5).mkString(" ") - - import mainargs.{ParserForMethods, Flag, arg, main as m} - - @m - def run( - @arg( - name = "decrypt", - short = 'd', - doc = "Decrypt input (default is encrypt)" - ) decrypt: Flag, - @arg( - name = "settings", - doc = "\"names rings [positions] reflector [plug-pairs]\"" - ) - settingsStr: String, - @arg(name = "f", short = 'f', doc = "Read message text from FILE") - file: Option[String], - @arg(doc = "Optional message TEXT (otherwise stdin)") - text: String* - ): Unit = - val encryptMode = !decrypt.value - - val input: String = - file - .map(readFile) - .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) - .getOrElse(readAllStdin()) - - import default.given - import Functor.tryFunctor - - given settings: Settings = Settings(settingsStr) - - def encodePreamble = - val enc = input.encrypted - val (start, key, message) = enc - .splitWith: - case IArray(start, key, message) => - Success( - (glyph(start).string, glyph(key).string, glyph(message).string) - ) - .get - s"$start $key ${group5(message)}" - - def decodePreamble = - val arr = input.split(" ") - val start = arr(0).glyph - val key = arr(1).glyph - val message = arr.drop(2).mkString.glyph - val enc = Encrypted[Try, String]( - Success(CipherText(start.iarray, key.iarray, message.iarray)) - ) - enc.decrypted.get - - def encode = group5(run(input.glyph).string) - - def decode = run(input.glyph).string - - val result = (encryptMode, settings.preamble) match - case (true, true) => encodePreamble - case (true, false) => encode - case (false, true) => decodePreamble - case (false, false) => decode - - println(result) - - def main(args: Array[String]): Unit = - ParserForMethods(Enigma).runOrExit(args) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala index 49a70b2..8ca6b4b 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala @@ -2,6 +2,8 @@ package cryptic package cipher package enigma +import scala.util.{Failure, Success, Try} + case class Settings( rotors: Rotors, reflector: Reflector, @@ -20,32 +22,29 @@ case class Settings( def pos(pos: IArray[Glyph]): Settings = copy(rotors = rotors.pos(pos)) object Settings: - /** Parses a settings string to construct a `Settings` object with the - * specified rotors, reflector, and plugboard configuration. - * - * The settings string must follow the format: "names rings [positions] - * reflector [plugboard]" - * - `names`: Hyphen-separated rotor identifiers from left-most to - * right-most, e.g., "III-II-I". - * - `rings`: A sequence of letters representing the ring settings, e.g., - * "AAA". - * - `positions` (optional): A sequence of letters representing the initial - * rotor positions, e.g., "AAZ". If omitted, defaults to "A" for each - * rotor. - * - `reflector`: A single letter indicating the reflector type, e.g., "B". - * - `plugboard` (optional): A string of paired letters representing - * plugboard connections, e.g., "ABCD". - * - * @param settings - * A string specifying the rotor names, ring settings, initial rotor - * positions, reflector type, and optional plugboard configuration. - * @return - * A `Settings` instance constructed based on the provided settings string. - * @throws IllegalArgumentException - * If the settings string does not match the required format or contains - * invalid values. - */ - def apply(settings: String): Settings = + /** + * Constructs a new `Settings` instance by parsing the provided configuration string. + * + * @param settings + * A string containing the configuration settings in the format + * `"names rings [positions] reflector [plugboard]"`. + * @return + * The `Settings` instance constructed from the parsed configuration. + * Throws an exception if the string format is invalid. + */ + def apply(settings: String): Settings = parse(settings).get + /** + * Parses a configuration string and attempts to construct a `Settings` instance. + * + * @param settings + * A string containing the configuration settings in the format + * `"names rings [positions] reflector [plugboard]"`. For example, + * `"III-II-I AAA AAZ B ABCD"`. + * @return + * A `Try[Settings]` containing the successfully parsed `Settings` instance, + * or a `Failure` with an `IllegalArgumentException` if the string format is invalid. + */ + def parse(settings: String): Try[Settings] = // Match parts: names, rings, optional positions, reflector, optional plugboard val SettingsFormat = """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])\s*([A-Za-z]*)\s*$""".r @@ -58,8 +57,10 @@ object Settings: val rotors = Rotors(s"$names $rings $pos") val reflector = Reflector.valueOf(refl.toUpperCase) val plugboard = PlugBoard(pb) - Settings(rotors, reflector, plugboard, preamble) + Success(Settings(rotors, reflector, plugboard, preamble)) case _ => - throw IllegalArgumentException( - """Settings requires format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" + Failure( + IllegalArgumentException( + s"""Invalid settings: $settings. Required format "names rings [positions] reflector [plugboard]" (e.g. "III-II-I AAA AAZ B ABCD")""" + ) ) From 85aed395230f1d2f4701eaec780abc8877019503 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Wed, 3 Dec 2025 00:42:09 +0100 Subject: [PATCH 09/13] Refactor CLI operations and improve error handling: - Renamed CLI parameters for better clarity (e.g., `decrypt` to `decryptMode`). - Improved processing flow by consolidating operations into `process` and `reportError` methods. - Enhanced preamble encoding/decoding with stricter input validation and error messaging. - Removed redundant imports and streamlined `Settings` handling logic. --- .../scala/cryptic/cipher/enigma/CLI.scala | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala index 206d4d2..2c03dbe 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala @@ -31,6 +31,8 @@ object CLI: private def group5(s: String): String = s.grouped(5).mkString(" ") import mainargs.{ParserForMethods, Flag, arg, main as m} + import Enigma.default.given + import Functor.tryFunctor @m(name = "enigma") def run( @@ -38,7 +40,7 @@ object CLI: name = "decrypt", short = 'd', doc = "Decrypt input (default is encrypt)" - ) decrypt: Flag, + ) decryptMode: Flag, @arg( name = "settings", short = 's', @@ -52,10 +54,11 @@ object CLI: text: String* ): Unit = - val encryptMode = !decrypt.value - - import Enigma.default.{given, *} - import Functor.tryFunctor + val encryptMode = !decryptMode.value + settings + .map(process) + .flatten + .fold(reportError, println) def settings: Try[Settings] = settingsStr @@ -75,53 +78,54 @@ object CLI: .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) .getOrElse(readAllStdin()) - def encodePreamble(input: String)(using Settings): Try[String] = - input.encrypted - .splitWith: - case IArray(start, key, message) => - Success( - (glyph(start).string, glyph(key).string, glyph(message).string) + def encodePreamble(using Settings): Try[String] = + input.flatMap: + _.encrypted + .splitWith: + case IArray(start, key, message) => + Success( + (start.glyph.string, key.glyph.string, message.glyph.string) + ) + .map: + case (start, key, message) => s"$start $key ${group5(message)}" + + def decodePreamble(using Settings): Try[String] = + input.flatMap: + _.split(" ") match + case Array(s, k, tail*) => + val start = s.glyph.iarray + val key = k.glyph.iarray + val message = tail.mkString.glyph.iarray + Encrypted[Try, String]( + Success(CipherText(start, key, message)) + ).decrypted + case _ => + Failure( + new IllegalArgumentException( + "Invalid input format: expected start and key" + ) ) - .map: - case (start, key, message) => - s"$start $key ${group5(message)}" - - def decodePreamble(input: String)(using Settings): Try[String] = - val arr = input.split(" ") - val start = arr(0).glyph - val key = arr(1).glyph - val message = arr.drop(2).mkString.glyph - val enc = Encrypted[Try, String]( - Success(CipherText(start.iarray, key.iarray, message.iarray)) - ) - enc.decrypted - def encode(input: String)(using Settings): Try[String] = - Try(group5(Enigma.run(input.glyph).string)) + def encode(using Settings): Try[String] = + input.map: i => + group5(Enigma.run(i.glyph).string) - def decode(input: String)(using Settings): Try[String] = - Try(Enigma.run(input.glyph).string) + def decode(using Settings): Try[String] = + input.map: i => + Enigma.run(i.glyph).string - def selectOperation(input: String)(using settings: Settings): Try[String] = - val op = (encryptMode, settings.preamble) match + def process(settings: Settings): Try[String] = + given s: Settings = settings + + (encryptMode, settings.preamble) match case (true, true) => encodePreamble case (true, false) => encode case (false, true) => decodePreamble case (false, false) => decode - op(input) - - val result = for - s <- settings - i <- input - result <- selectOperation(i)(using s) - yield result - result.fold( - throwable => - Console.err.println(throwable.getMessage) - Console.err.println(usage()) - sys.exit(1) - , - println - ) + + def reportError(throwable: Throwable) = + Console.err.println(throwable.getMessage) + Console.err.println(usage()) + sys.exit(1) def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) From 3f28555202b3166c5ac0d44bde05aaf1e96a5eee Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Wed, 3 Dec 2025 01:08:00 +0100 Subject: [PATCH 10/13] Refactor Enigma CLI input and processing logic: - Merged input handling methods (`readAllStdin`, `readFile`) to avoid redundancy. - Introduced `group5` extension for consistent five-letter grouping. - Simplified encoding/decoding workflows by consolidating redundant logic. - Improved readability of `encode` and `decode` methods using streamlined transformations. --- .../scala/cryptic/cipher/enigma/CLI.scala | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala index 2c03dbe..b7a2556 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala @@ -24,12 +24,6 @@ object CLI: |Encrypt output is grouped into five-letter groups. Decrypt output is a continuous string. |""".stripMargin - private def readAllStdin(): String = Source.stdin.slurp - - private def readFile(path: String): String = Source.fromFile(path).slurp - - private def group5(s: String): String = s.grouped(5).mkString(" ") - import mainargs.{ParserForMethods, Flag, arg, main as m} import Enigma.default.given import Functor.tryFunctor @@ -71,12 +65,13 @@ object CLI: ) )(Settings.parse) - def input: Try[String] = - Try: - file - .map(readFile) - .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) - .getOrElse(readAllStdin()) + def process(settings: Settings): Try[String] = + given s: Settings = settings + (encryptMode, settings.preamble) match + case (true, true) => encodePreamble + case (true, false) => encode + case (false, true) => decodePreamble + case (false, false) => decode def encodePreamble(using Settings): Try[String] = input.flatMap: @@ -84,10 +79,14 @@ object CLI: .splitWith: case IArray(start, key, message) => Success( - (start.glyph.string, key.glyph.string, message.glyph.string) + ( + start.glyph.string, + key.glyph.string, + message.glyph.string.group5 + ) ) .map: - case (start, key, message) => s"$start $key ${group5(message)}" + case (start, key, message) => s"$start $key $message" def decodePreamble(using Settings): Try[String] = input.flatMap: @@ -106,26 +105,30 @@ object CLI: ) ) - def encode(using Settings): Try[String] = - input.map: i => - group5(Enigma.run(i.glyph).string) + def encode(using Settings): Try[String] = decode.map(_.group5) def decode(using Settings): Try[String] = - input.map: i => - Enigma.run(i.glyph).string + input + .map(_.glyph) + .map(Enigma.run) + .map(_.string) - def process(settings: Settings): Try[String] = - given s: Settings = settings + def input: Try[String] = + Try: + file + .map(readFile) + .orElse(if text.nonEmpty then Some(text.mkString(" ")) else None) + .getOrElse(readAllStdin()) - (encryptMode, settings.preamble) match - case (true, true) => encodePreamble - case (true, false) => encode - case (false, true) => decodePreamble - case (false, false) => decode + def readAllStdin(): String = Source.stdin.slurp + + def readFile(path: String): String = Source.fromFile(path).slurp def reportError(throwable: Throwable) = Console.err.println(throwable.getMessage) Console.err.println(usage()) sys.exit(1) + extension (s: String) def group5: String = s.grouped(5).mkString(" ") + def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) From 9acfa3866ce8d72e420271c497a996d952d85fe8 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Wed, 3 Dec 2025 01:09:57 +0100 Subject: [PATCH 11/13] Document Enigma CLI functionality with detailed Scaladoc: - Added comprehensive Scaladoc for the `CLI` object, describing arguments, methods, and extensions. - Improved documentation of encryption/decryption workflows, input handling, and usage examples. --- .../scala/cryptic/cipher/enigma/CLI.scala | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala index b7a2556..1be209e 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala @@ -5,6 +5,49 @@ package enigma import scala.io.Source import scala.util.{Failure, Success, Try} +/** + * Command-line interface for the Enigma encryption/decryption tool. + * + * Provides functionality to encrypt or decrypt messages using specific settings. + * The input can be provided directly via text or read from a file. + * + * Arguments: + * + * - `-d, --decrypt`: Specifies decryption mode. Encrypt mode is the default. + * - `-s, --settings SETTINGS`: Specifies the Enigma settings as a single string, e.g., "III-II-I AAA AAZ B ABCD". + * This is optional if the environment variable `ENIGMA_SETTINGS` is set. This argument overrides the environment variable if provided. + * - `-f, --file FILE`: Reads the input message from a specified file. + * - `TEXT`: The input message text (optional if the input is provided via a file or stdin). + * + * Notes: + * + * - Input text is normalized to uppercase letters A–Z, ignoring non-alphabetical characters. + * - When encrypting, the output is grouped into five-letter blocks. + * - When decrypting, the output is returned as a continuous string. + * + * Methods: + * + * - `run`: Parses and processes command-line arguments for encryption or decryption. + * - `main`: Entry point for the application. Accepts and processes command-line arguments. + * + * Helper Methods: + * + * - `usage`: Returns a string describing the CLI usage and options. + * - `settings`: Parses and validates the Enigma machine settings. + * - `process`: Determines encryption or decryption logic based on the settings. + * - `encodePreamble`: Encrypts the input including the preamble. + * - `decodePreamble`: Decrypts the input including the preamble. + * - `encode`: Encrypts the input message. + * - `decode`: Decrypts the input message. + * - `input`: Handles input retrieval from text, file, or stdin. + * - `readAllStdin`: Reads input from standard input. + * - `readFile`: Reads input from a file. + * - `reportError`: Logs and prints error messages. + * + * Extensions: + * + * - `group5`: String extension method to format output into groups of five letters. + */ object CLI: private def usage(): String = """ From 97ee7670eacf96754249c3d0fb9c4847b089a413 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Wed, 3 Dec 2025 01:27:16 +0100 Subject: [PATCH 12/13] Document core Enigma components with detailed Scaladoc: - Added comprehensive Scaladoc for `Rotor`, `Wheel`, `Glyph`, `Rotors`, and `PlugBoard` classes. - Improved clarity on behaviors, constructors, and usage examples in all core Enigma components. - Enhanced validation and operation explanations, aligning documentation with recent refactoring improvements. --- .../scala/cryptic/cipher/enigma/Enigma.scala | 1 - .../scala/cryptic/cipher/enigma/Glyph.scala | 26 ++++++++ .../cryptic/cipher/enigma/PlugBoard.scala | 65 +++++++++---------- .../scala/cryptic/cipher/enigma/Rotor.scala | 22 ++++++- .../scala/cryptic/cipher/enigma/Rotors.scala | 7 ++ .../scala/cryptic/cipher/enigma/Wheel.scala | 21 ++++++ 6 files changed, 105 insertions(+), 37 deletions(-) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala index 70b0298..88cfcac 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Enigma.scala @@ -3,7 +3,6 @@ package cipher package enigma import scala.util.{Failure, Success, Try} -import scala.io.Source object Enigma: diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala index f37e3a8..6d8fea0 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Glyph.scala @@ -8,6 +8,32 @@ import scala.util.{Failure, Success, Try} val secureRandom = SecureRandom() opaque type Glyph = Int +/** Represents the Glyph object, which provides operations related to the encoding and + * construction of glyphs for cryptographic purposes. + * + * Constants: + * - `mod`: A constant value indicating the modulus used for glyph calculations. + * - `base`: A constant value indicating the ASCII value offset for character conversions. + * - `one`: A predefined Glyph instance representing the glyph value of 1. + * + * Factory Methods: + * - `apply(i: Int): Glyph`: Constructs a Glyph instance from an integer by applying the + * modulus operation such that the resulting value is normalized between 0 and `mod - 1`. + * - `apply(c: Char): Glyph`: Constructs a Glyph instance from a character by subtracting + * the ASCII offset (`base`) and normalizing the result using the modulus operation. + * + * Utility Methods: + * - `random(n: Int): IArray[Glyph]`: Generates an immutable array containing `n` randomly + * generated Glyph instances. Each Glyph value is constrained by the modulus and generated + * securely using a random number generator. + * - `unsafe(c: Char): Try[Glyph]`: Attempts to construct a Glyph instance from a character + * input. Accepts letters A–Z or a–z, automatically converting lowercase to uppercase. + * Returns a `Success` containing the Glyph if valid, otherwise returns a `Failure` with + * an `IllegalArgumentException`. + * - `isValid(c: Char): Boolean`: Checks if a character can be converted to a valid Glyph + * representation following the same criteria as `unsafe`. Returns `true` if valid, otherwise + * `false`. + */ object Glyph: val mod = 26 val base = 65 diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala index 3502dd6..4ec3e6d 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala @@ -6,61 +6,60 @@ package enigma * * @param wiring * Zero to ten disjoint pairs of unique `Glyph`s. Each `Glyph` may appear at - * most once across all pairs, and the two `Glyph`s within a pair must differ. + * most once across all pairs, and the two `Glyph`s within a pair must + * differ. */ -case class PlugBoard(wiring: IArray[(Glyph, Glyph)]) - : - // Validate constraints +case class PlugBoard(wiring: IArray[(Glyph, Glyph)]): require(wiring.length <= 10, "PlugBoard may contain at most 10 pairs") require( wiring.forall((a, b) => a != b), "PlugBoard pairs must map two different letters (no self-pairs)" ) - private val allGlyphs: IArray[Glyph] = IArray.from(wiring.flatMap((a, b) => Array(a, b))) + private val allGlyphs: IArray[Glyph] = + IArray.from(wiring.flatMap((a, b) => Array(a, b))) require( allGlyphs.distinct.length == allGlyphs.length, "PlugBoard letters must be unique across all pairs" ) - /** Swap the provided `Glyph` using the configured wiring. If the `Glyph` - * is not present in any pair, it is returned unchanged. + /** Swap the provided `Glyph` using the configured wiring. If the `Glyph` is + * not present in any pair, it is returned unchanged. */ def swap(g: Glyph): Glyph = - // simple linear search across pairs; wiring limited to 10 so this is fine - var i = 0 - while i < wiring.length do - val (a, b) = wiring(i) - if g == a then return b - if g == b then return a - i += 1 - g + wiring + .collectFirst: + case (a, b) if g == a => b + case (a, b) if g == b => a + .getOrElse(g) object PlugBoard: /** Construct a PlugBoard from a concatenated string of letter pairs with no - * spaces, e.g. "ABCD" -> pairs (A,B), (C,D). Lowercase letters are - * accepted and normalized to uppercase. The total number of pairs must be - * between 0 and 10 (i.e. length 0 to 20), and letters must be unique. + * spaces, e.g. "ABCD" -> pairs (A,B), (C,D). Lowercase letters are accepted + * and normalized to uppercase. The total number of pairs must be between 0 + * and 10 (i.e. length 0 to 20), and letters must be unique. */ def apply(pairs: String): PlugBoard = val s = pairs.trim - // Validate characters: only letters allowed if s.nonEmpty && !s.forall(_.isLetter) then - throw IllegalArgumentException("PlugBoard.apply requires only letters A–Z or a–z") - // Normalize to Glyphs (which upper-cases and validates) + throw IllegalArgumentException( + "PlugBoard.apply requires only letters A–Z or a–z" + ) val glyphs: IArray[Glyph] = s.glyph if glyphs.length != s.length then - // This would only happen if non-letters were present; be explicit - throw IllegalArgumentException("PlugBoard.apply contains invalid characters") + throw IllegalArgumentException( + "PlugBoard.apply contains invalid characters" + ) if glyphs.length % 2 != 0 then - throw IllegalArgumentException("PlugBoard.apply requires an even number of letters (pairs)") + throw IllegalArgumentException( + "PlugBoard.apply requires an even number of letters (pairs)" + ) val pairCount = glyphs.length / 2 if pairCount > 10 then - throw IllegalArgumentException("PlugBoard may contain at most 10 pairs (20 letters)") - val arr = new Array[(Glyph, Glyph)](pairCount) - var i = 0 - while i < pairCount do - val a = glyphs(i * 2) - val b = glyphs(i * 2 + 1) - arr(i) = (a, b) - i += 1 - PlugBoard(IArray.from(arr)) + throw IllegalArgumentException( + "PlugBoard may contain at most 10 pairs (20 letters)" + ) + + val wiring = IArray.tabulate(pairCount): i => + (glyphs(i * 2), glyphs(i * 2 + 1)) + + new PlugBoard(wiring) diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala index 070bb65..088816c 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala @@ -2,11 +2,29 @@ package cryptic package cipher package enigma +/** Represents a rotor in an Enigma-like cryptographic device. A rotor handles + * letter substitution and position-based encoding/decoding as part of a series + * of wheels. + * + * @constructor + * Creates a Rotor with a specified wheel, ring setting, and initial + * position. + * @param wheel + * The wheel defining the rotor's wiring and notches for carry operations. + * @param ring + * The ring setting, which shifts the internal wiring relative to the outer + * casing. + * @param pos + * The current position of the rotor. + * @define operation + * Each operation (`in` or `out`) transposes values by accounting for the + * rotor's offset, allowing for directional encoding and decoding of inputs. + */ case class Rotor(wheel: Wheel, ring: Glyph, pos: Glyph): def rotate: Rotor = copy(pos = pos.++) val offset: Glyph = pos - ring def carry: Boolean = wheel.carry(pos) - def previousCarry:Boolean = wheel.carry(pos.--) + def previousCarry: Boolean = wheel.carry(pos.--) def in(g: Glyph): Glyph = wheel.in(g + offset) - offset def in(c: Char): Char = in(c.glyph).char @@ -37,8 +55,6 @@ object Rotor: * alphabetic characters. */ def apply(settings: String): Rotor = - // Regex that tolerates leading/trailing whitespace, requires exactly three tokens, and - // enforces single alphabetic characters for ring and pos. val Settings = """^\s*([^\s]+)\s+([A-Za-z])\s+([A-Za-z])\s*$""".r settings match diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala index bd68878..ca1907b 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala @@ -2,6 +2,13 @@ package cryptic package cipher package enigma +/** Represents a sequence of rotors in an Enigma-like cryptographic device. + * + * @param rotors + * An immutable array of Rotors representing the rotor sequence. + * The rightmost rotor should be placed at index 0. The sequence must be non-empty + * and must not contain duplicate rotors. + */ case class Rotors(rotors: IArray[Rotor]): require( rotors.nonEmpty, diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala index 26a18c8..c0696a4 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Wheel.scala @@ -4,12 +4,33 @@ package enigma import scala.util.Try +/** + * Represents a Wheel (or Rotor) used in an Enigma-like device. + * + * A Wheel defines a specific wiring configuration and one or more notches + * that determine when it triggers a carry operation to the next rotor. + * + * @constructor Creates a Wheel with a specific name, wiring configuration, and + * associated notches. + * @param name The name of the wheel, typically indicating its type or version. + * @param wiring An indexed sequence of Glyphs representing the wiring configuration + * used for letter substitution. + * @param notches An indexed sequence of Glyphs indicating the positions at + * which this wheel causes a carry operation. + */ case class Wheel(name: String, wiring: IArray[Glyph], notches: IArray[Glyph]): def carry(pos: Glyph): Boolean = notches.contains(pos) def in(g: Glyph): Glyph = wiring(g.int) def out(g: Glyph): Glyph = Glyph(wiring.indexOf(g)) override def toString: String = name +/** + * Companion object for the `Wheel` class, providing factory methods and predefined configurations. + * + * The `Wheel` object allows for the creation of `Wheel` instances, either from scratch with + * specified wiring and notch configurations, or by referencing predefined configurations + * by their conventional names (e.g., "I", "II", etc.). + */ object Wheel: // Predefined rotor singletons (kept private) to preserve reference and structural equality // while "inlining" access through the name-based apply. From eb3c689cdf1718ad936f15dbdf6032f280595584 Mon Sep 17 00:00:00 2001 From: Martin Zachrison Date: Sun, 7 Dec 2025 22:35:21 +0100 Subject: [PATCH 13/13] Refactor core Enigma components and tests: - Replaced `IArray` with `Seq` in `Rotors` and `PlugBoard` for improved usability and consistency. - Enhanced `Rotor.toString` for better readability and alignment with user-friendly formatting. - Revised `Settings.parse` regex to enforce consistent spacing, improving input validation. - Simplified `PlugBoardSpec` tests by introducing property-based checks for plugboard behavior. - Added `SettingsSpec` to validate different configurations and edge cases. - Refactored test cases across components to improve clarity, coverage, and maintainability. --- .../cryptic/cipher/enigma/PlugBoard.scala | 5 +- .../scala/cryptic/cipher/enigma/Rotor.scala | 2 +- .../scala/cryptic/cipher/enigma/Rotors.scala | 4 +- .../cryptic/cipher/enigma/Settings.scala | 6 +- .../cryptic/cipher/enigma/PlugBoardSpec.scala | 82 +++++++++++-------- .../cryptic/cipher/enigma/RotorSpec.scala | 3 + .../cryptic/cipher/enigma/RotorsSpec.scala | 22 ++--- .../cryptic/cipher/enigma/SettingsSpec.scala | 38 +++++++++ 8 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala index 4ec3e6d..cd64f41 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/PlugBoard.scala @@ -9,7 +9,7 @@ package enigma * most once across all pairs, and the two `Glyph`s within a pair must * differ. */ -case class PlugBoard(wiring: IArray[(Glyph, Glyph)]): +case class PlugBoard(wiring: Seq[(Glyph, Glyph)]): require(wiring.length <= 10, "PlugBoard may contain at most 10 pairs") require( wiring.forall((a, b) => a != b), @@ -22,6 +22,8 @@ case class PlugBoard(wiring: IArray[(Glyph, Glyph)]): "PlugBoard letters must be unique across all pairs" ) + override def toString: String = wiring.map((f,t)=>s"${f.string}${t.string}").mkString + /** Swap the provided `Glyph` using the configured wiring. If the `Glyph` is * not present in any pair, it is returned unchanged. */ @@ -33,6 +35,7 @@ case class PlugBoard(wiring: IArray[(Glyph, Glyph)]): .getOrElse(g) object PlugBoard: + val empty = PlugBoard(Seq.empty) /** Construct a PlugBoard from a concatenated string of letter pairs with no * spaces, e.g. "ABCD" -> pairs (A,B), (C,D). Lowercase letters are accepted * and normalized to uppercase. The total number of pairs must be between 0 diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala index 088816c..03de0de 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotor.scala @@ -32,7 +32,7 @@ case class Rotor(wheel: Wheel, ring: Glyph, pos: Glyph): def out(g: Glyph): Glyph = wheel.out(g + offset) - offset def out(c: Char): Char = out(c.glyph).char - override def toString: String = s"Rotor($wheel ${ring.char} ${pos.char})" + override def toString: String = s"$wheel ${ring.string} ${pos.string}" object Rotor: /** Convenience constructor for tests and callers that prefer simple types. diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala index ca1907b..87d3e00 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Rotors.scala @@ -9,7 +9,7 @@ package enigma * The rightmost rotor should be placed at index 0. The sequence must be non-empty * and must not contain duplicate rotors. */ -case class Rotors(rotors: IArray[Rotor]): +case class Rotors(rotors: Seq[Rotor]): require( rotors.nonEmpty, "Rotors must contain at least one rotor state (right-most at index 0)" @@ -61,7 +61,7 @@ case class Rotors(rotors: IArray[Rotor]): * @return * An immutable array of `Glyph` representing the positions of the rotors. */ - def pos: IArray[Glyph] = rotors.map(_.pos) + def pos: Seq[Glyph] = rotors.map(_.pos) /** * Updates the positions of the rotors in the sequence to the specified positions. diff --git a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala index 8ca6b4b..2c56523 100644 --- a/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala +++ b/cipher-enigma/src/main/scala/cryptic/cipher/enigma/Settings.scala @@ -47,16 +47,16 @@ object Settings: def parse(settings: String): Try[Settings] = // Match parts: names, rings, optional positions, reflector, optional plugboard val SettingsFormat = - """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])\s*([A-Za-z]*)\s*$""".r + """^\s*(\S+)\s+([A-Za-z]+)\s+(?:([A-Za-z]+)\s+)?([A-Ca-c])(?:\s+([A-Za-z]*))?\s*$""".r settings match - case SettingsFormat(names, rings, posOrNull, refl, pb) => + case SettingsFormat(names, rings, posOrNull, refl, pbOrNull) => val posOption = Option(posOrNull) val preamble = posOption.isEmpty val pos = posOption.getOrElse("A" * rings.length) val rotors = Rotors(s"$names $rings $pos") val reflector = Reflector.valueOf(refl.toUpperCase) - val plugboard = PlugBoard(pb) + val plugboard = PlugBoard(Option(pbOrNull).getOrElse("")) Success(Settings(rotors, reflector, plugboard, preamble)) case _ => Failure( diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala index d456ef2..fabcb1a 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/PlugBoardSpec.scala @@ -4,49 +4,61 @@ package enigma import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks -class PlugBoardSpec extends AnyFlatSpec with Matchers: +class PlugBoardSpec + extends AnyFlatSpec + with Matchers + with TableDrivenPropertyChecks: behavior of "PlugBoard" - it should "swap pairs and be identity for others" in: + it should "swap mapped glyphs and leave others unchanged" in: val pb = PlugBoard("ABCD") // A<->B, C<->D - pb.swap('A'.glyph).char shouldBe 'B' - pb.swap('B'.glyph).char shouldBe 'A' - pb.swap('C'.glyph).char shouldBe 'D' - pb.swap('D'.glyph).char shouldBe 'C' - // Unplugged letters unchanged - pb.swap('E'.glyph).char shouldBe 'E' - - it should "be symmetric (swap twice yields original)" in: - val pb = PlugBoard("QWERTY") // Q<->W, E<->R, T<->Y - ('A' to 'Z').foreach: c => - val g = c.glyph - pb.swap(pb.swap(g)) shouldBe g - - it should "accept empty wiring and act as identity" in: + val data = Table( + ("in", "out"), + ('A', 'B'), + ('B', 'A'), + ('C', 'D'), + ('D', 'C'), + ('Z', 'Z') // unaffected + ) + forAll(data): (in: Char, out: Char) => + pb.swap(in.glyph) shouldBe out.glyph + + it should "support empty wiring (no-op)" in: val pb = PlugBoard("") - ('A' to 'Z').foreach: c => - pb.swap(c.glyph).char shouldBe c + pb.swap('A'.glyph) shouldBe 'A'.glyph + pb.swap('Z'.glyph) shouldBe 'Z'.glyph - it should "parse lowercase and uppercase letters equivalently" in: - val up = PlugBoard("ABCD") - val lo = PlugBoard("abCd") - ('A' to 'Z').foreach: c => - up.swap(c.glyph) shouldBe lo.swap(c.glyph) + it should "normalize lowercase input when constructing from String" in: + val pb = PlugBoard("abCd") + // toString uses uppercase glyphs + pb.toString shouldBe "ABCD" + // swap behavior operates on Glyphs; use uppercase inputs + pb.swap('A'.glyph) shouldBe 'B'.glyph + pb.swap('C'.glyph) shouldBe 'D'.glyph - it should "reject odd number of letters" in: - an[IllegalArgumentException] should be thrownBy PlugBoard("ABC") + behavior of "PlugBoard.apply(pairs: String)" - it should "reject duplicate letters across pairs" in: - // 'A' duplicated - an[IllegalArgumentException] should be thrownBy PlugBoard("ABAC") + it should "reject non-letter characters" in: + intercept[IllegalArgumentException] { PlugBoard("AB12") } + intercept[IllegalArgumentException] { PlugBoard("--__ ") } - it should "reject self-pairs like AA" in: - an[IllegalArgumentException] should be thrownBy PlugBoard("AABC") + it should "reject an odd number of letters" in: + intercept[IllegalArgumentException] { PlugBoard("ABC") } - it should "reject more than 10 pairs" in: - val twentyTwo = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".take(22) // 11 pairs - an[IllegalArgumentException] should be thrownBy PlugBoard(twentyTwo) + it should "reject more than 10 pairs (20 letters)" in: + intercept[IllegalArgumentException] { PlugBoard("ABCDEFGHIJKLMNOPQRSTUVWXYZ") } - it should "reject non-letter characters" in: - an[IllegalArgumentException] should be thrownBy PlugBoard("AB- CD") + it should "reject self-pairs and duplicate letters across pairs" in: + // self-pair AA + intercept[IllegalArgumentException] { PlugBoard("AA") } + // duplicate A used in two pairs + intercept[IllegalArgumentException] { PlugBoard("ABAC") } + + it should "preserve pair order and render as concatenated pairs in toString" in: + PlugBoard("ABCD").toString shouldBe "ABCD" + PlugBoard("EFGH").toString shouldBe "EFGH" + + it should "construct an empty PlugBoard from an empty string" in: + PlugBoard("").wiring.isEmpty shouldBe true diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala index 21e49df..44c23b8 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorSpec.scala @@ -102,3 +102,6 @@ class RotorSpec it should "reject unknown wheel names via Wheel" in: intercept[IllegalArgumentException] { Rotor("VII A A") } + + it should "have a toString matching apply" in: + Rotor("IV Z Q").toString shouldBe "IV Z Q" diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala index d1f4a33..a0e6570 100644 --- a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/RotorsSpec.scala @@ -50,7 +50,7 @@ class RotorsSpec val single0 = Rotors(IArray(Rotor("I A A"))) val single1 = single0.rotate single1.rotors.length shouldBe 1 - single1.rotors(0).pos shouldBe 'B'.glyph + single1.rotors.head.pos shouldBe 'B'.glyph it should "ripple carry across four rotors" in: // Set up a chain where first rotation causes cascading carries: @@ -76,15 +76,9 @@ class RotorsSpec } // 1, 2, 3, 4 should all be allowed noException should be thrownBy Rotors(IArray(Rotor("I A A"))) - noException should be thrownBy Rotors( - IArray(Rotor("I A A"), Rotor("II A A")) - ) - noException should be thrownBy Rotors( - IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A")) - ) - noException should be thrownBy Rotors( - IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"), Rotor("IV A A")) - ) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"))) + noException should be thrownBy Rotors(IArray(Rotor("I A A"), Rotor("II A A"), Rotor("III A A"), Rotor("IV A A"))) it should "reject duplicate rotors (by identity/name)" in: // Duplicate rotor I used twice should fail @@ -115,7 +109,7 @@ class RotorsSpec val single = Rotors(IArray(Rotor("I A A"))) val g = 'C'.glyph - single.in(g) shouldBe (single.rotors(0).in(g), List(2, 12)) + single.in(g) shouldBe (single.rotors.head.in(g), List(2, 12)) single.in('C') shouldBe single.in(g.char) behavior of "Rotors companion object apply" @@ -123,7 +117,7 @@ class RotorsSpec it should "construct from varargs with at least one state (single)" in: val single = Rotors(Rotor("I A A")) single.rotors.length shouldBe 1 - single.rotors(0).pos shouldBe 'A'.glyph + single.rotors.head.pos shouldBe 'A'.glyph it should "construct from varargs with multiple states and preserve order" in: val r0 = Rotor("I A A") @@ -131,7 +125,7 @@ class RotorsSpec val r2 = Rotor("III A C") val rotors = Rotors(r0, r1, r2) rotors.rotors.length shouldBe 3 - rotors.rotors(0) shouldBe r0 + rotors.rotors.head shouldBe r0 rotors.rotors(1) shouldBe r1 rotors.rotors(2) shouldBe r2 @@ -161,5 +155,5 @@ class RotorsSpec val single = Rotors(IArray(Rotor("I A A"))) val g = 'M'.glyph - single.out(g) shouldBe (single.rotors(0).out(g), List(12, 2)) + single.out(g) shouldBe (single.rotors.head.out(g), List(12, 2)) single.out('M') shouldBe single.out(g.char) diff --git a/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala new file mode 100644 index 0000000..08a8f38 --- /dev/null +++ b/cipher-enigma/src/test/scala/cryptic/cipher/enigma/SettingsSpec.scala @@ -0,0 +1,38 @@ +package cryptic +package cipher +package enigma + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.TryValues + +class SettingsSpec extends AnyFlatSpec with Matchers with TryValues: + behavior of "Settings" + "Settings" should "parse with start positions" in: + Settings.parse("III-II-I ABC DEF C ABTD").success.value shouldBe Settings( + Rotors("III-II-I ABC DEF"), + Reflector.C, + PlugBoard("ABTD"), + false + ) + "Settings" should "parse with without start positions" in: + Settings.parse("III-II-I ABC C ABTD").success.value shouldBe Settings( + Rotors("III-II-I ABC AAA"), + Reflector.C, + PlugBoard("ABTD"), + true + ) + "Settings" should "parse with start positions without plugboard" in: + Settings.parse("III-II-I ABC DEF C").success.value shouldBe Settings( + Rotors("III-II-I ABC DEF"), + Reflector.C, + PlugBoard.empty, + false + ) + "Settings" should "parse with with neither start positions nor plugboard" in: + Settings.parse("III-II-I ABC C").success.value shouldBe Settings( + Rotors("III-II-I ABC AAA"), + Reflector.C, + PlugBoard.empty, + true + )