Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 99 additions & 15 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -7,6 +10,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",
Expand Down Expand Up @@ -79,11 +83,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"
},
Expand All @@ -94,17 +101,17 @@ 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"
},
artifactName := { (sv: ScalaVersion, module: ModuleID, artifact: Artifact) =>
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"
},
Expand All @@ -115,7 +122,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"
},
Expand All @@ -127,7 +134,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"
},
Expand All @@ -138,9 +144,8 @@ lazy val cipherCommonSettings = (projectName: String) =>
)

lazy val cipherTestSettings =
commonSettings ++ testSettings ++ Seq(
commonSettings ++ Seq(
name := "cipher-test",
libraryDependencies ++= Seq(scalaTest % Test),
publish / skip := true
)

Expand All @@ -157,20 +162,93 @@ 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 ++= 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.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
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", _*) => 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 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 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
}
)
)
.dependsOn(core)

lazy val `cipher-test` = (project in file("cipher-test"))
.settings(cipherTestSettings)
.dependsOn(
Expand Down Expand Up @@ -200,5 +278,11 @@ lazy val cryptic = (project in file("."))
`codec-upickle`,
`cipher-bouncycastle`,
`cipher-javax`,
`cipher-enigma`,
`cipher-test`
)

// 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"
)
177 changes: 177 additions & 0 deletions cipher-enigma/src/main/scala/cryptic/cipher/enigma/CLI.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package cryptic
package cipher
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 =
"""
|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

import mainargs.{ParserForMethods, Flag, arg, main as m}
import Enigma.default.given
import Functor.tryFunctor

@m(name = "enigma")
def run(
@arg(
name = "decrypt",
short = 'd',
doc = "Decrypt input (default is encrypt)"
) decryptMode: 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 = !decryptMode.value
settings
.map(process)
.flatten
.fold(reportError, println)

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 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:
_.encrypted
.splitWith:
case IArray(start, key, message) =>
Success(
(
start.glyph.string,
key.glyph.string,
message.glyph.string.group5
)
)
.map:
case (start, key, message) => s"$start $key $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"
)
)

def encode(using Settings): Try[String] = decode.map(_.group5)

def decode(using Settings): Try[String] =
input
.map(_.glyph)
.map(Enigma.run)
.map(_.string)

def input: Try[String] =
Try:
file
.map(readFile)
.orElse(if text.nonEmpty then Some(text.mkString(" ")) else None)
.getOrElse(readAllStdin())

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)
Loading