From 288a99d225d0db2f4c76c6a9a0c1e85d7e8e3007 Mon Sep 17 00:00:00 2001 From: Diego Casella Date: Sun, 12 May 2024 22:54:10 +0200 Subject: [PATCH] feat: add support for Scala 2 #14 * feat: add support for Scala 2 #14 * fix: nopenopenope, no braceless syntax :/ * fix: wip, traverse & log redacted fields * fix: y u no patch :/ * fix: it works! * fix: added test for curried case class ctor * fix: crosscompile for scala 2.12.x and updated 3.4.x * chore: updated README.md * chore: bump version --- README.md | 30 ++- build.sbt | 15 +- plugin/src/main/resources/scalac-plugin.xml | 4 + .../polentino/redacted/RedactedPlugin.scala | 14 ++ .../redacted/RedactedPluginComponent.scala | 182 ++++++++++++++++++ .../polentino/redacted/RedactedPlugin.scala | 2 +- .../polentino/redacted/helpers/AstOps.scala | 6 +- .../redacted/helpers/PluginOps.scala | 2 +- .../redacted/phases/PatchToString.scala | 18 +- .../polentino/redacted/RedactedSpec.scala | 44 ++++- version.sbt | 2 +- 11 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 plugin/src/main/resources/scalac-plugin.xml create mode 100644 plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPlugin.scala create mode 100644 plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPluginComponent.scala rename plugin/src/main/{scala => scala-3}/io/github/polentino/redacted/RedactedPlugin.scala (88%) rename plugin/src/main/{scala => scala-3}/io/github/polentino/redacted/helpers/AstOps.scala (80%) rename plugin/src/main/{scala => scala-3}/io/github/polentino/redacted/helpers/PluginOps.scala (99%) rename plugin/src/main/{scala => scala-3}/io/github/polentino/redacted/phases/PatchToString.scala (77%) diff --git a/README.md b/README.md index 2a38bb4..1b903aa 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ in your `build.sbt` file, add the following lines ```scala 3 val redactedVersion = // use latest version of the library -resolvers += DefaultMavenRepository, + resolvers += DefaultMavenRepository +, libraryDependencies ++= Seq( "io.github.polentino" %% "redacted" % redactedVersion cross CrossVersion.full, compilerPlugin("io.github.polentino" %% "redacted-plugin" % redactedVersion cross CrossVersion.full) @@ -131,6 +132,30 @@ println(wrapper) will print > Wrapper(id-1,***) +### Note on curried case classes + +While it is possible to write something like + +```scala 3 +case class Curried(id: String, @redacted name: String)(@redacted email: String) +``` + +the `toString` method that Scala compiler generates by default will print only the parameters in the primary +constructor, meaning that + +```scala 3 +val c = Curried(0, "Berfu")("berfu@gmail.com") +println(c) +``` + +will display + +```scala 3 +Curried(0,Berfu) +``` + +Therefore, the same behavior is being kept in the customized `toString` implementation. + ## How it works Given a case class with at least one field annotated with `@redacted`, i.e. @@ -157,7 +182,8 @@ implementation by selectively returning either the `***` string, or the value of ```scala 3 def toString(): String = - "(" + this. + "," + "***" + ... + ")" + "(" + this.< field not redacted > + "," + "***" + +...+")" ``` ## Improvements diff --git a/build.sbt b/build.sbt index 2a0f1f8..9092d4f 100644 --- a/build.sbt +++ b/build.sbt @@ -6,12 +6,14 @@ val scalaCheckVersion = "3.2.17.0" // all LTS versions & latest minor ones val supportedScalaVersions = List( + "2.12.19", + "2.13.13", "3.1.3", "3.2.2", "3.3.0", "3.3.1", "3.3.3", - "3.4.0" + "3.4.1" ) inThisBuild( @@ -64,7 +66,11 @@ lazy val redactedCompilerPlugin = (project in file("plugin")) .settings(name := "redacted-plugin") .settings( crossCompileSettings, - libraryDependencies += "org.scala-lang" %% "scala3-compiler" % scalaVersion.value + libraryDependencies += (CrossVersion.partialVersion(scalaVersion.value) match { + case Some((3, _)) => "org.scala-lang" %% "scala3-compiler" % scalaVersion.value + case Some((2, _)) => "org.scala-lang" % "scala-compiler" % scalaVersion.value + case v => throw new Exception(s"Scala version $v not recognised") + }) ) lazy val redactedTests = (project in file("tests")) @@ -80,9 +86,10 @@ lazy val redactedTests = (project in file("tests")) ), Test / scalacOptions ++= { val jar = (redactedCompilerPlugin / Compile / packageBin).value - val addPlugin = "-Xplugin:" + jar.getAbsolutePath + val addScala2Plugin = "-Xplugin-require:redacted-plugin" + val addScala3Plugin = "-Xplugin:" + jar.getAbsolutePath val dummy = "-Jdummy=" + jar.lastModified - Seq(addPlugin, dummy) + Seq(addScala2Plugin, addScala3Plugin, dummy) } ) diff --git a/plugin/src/main/resources/scalac-plugin.xml b/plugin/src/main/resources/scalac-plugin.xml new file mode 100644 index 0000000..4af80e0 --- /dev/null +++ b/plugin/src/main/resources/scalac-plugin.xml @@ -0,0 +1,4 @@ + + redacted-plugin + io.github.polentino.redacted.RedactedPlugin + \ No newline at end of file diff --git a/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPlugin.scala b/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPlugin.scala new file mode 100644 index 0000000..0328b73 --- /dev/null +++ b/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPlugin.scala @@ -0,0 +1,14 @@ +package io.github.polentino.redacted + +import scala.tools.nsc._ +import scala.tools.nsc.plugins.Plugin +import scala.tools.nsc.plugins.PluginComponent + +final class RedactedPlugin(override val global: Global) extends Plugin { + + override val name: String = "redacted-plugin" + + override val description: String = "Plugin to prevent leaking sensitive data when logging case classes" + + override val components: List[PluginComponent] = List(new RedactedPluginComponent(global)) +} diff --git a/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPluginComponent.scala b/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPluginComponent.scala new file mode 100644 index 0000000..09bd079 --- /dev/null +++ b/plugin/src/main/scala-2/io/github/polentino/redacted/RedactedPluginComponent.scala @@ -0,0 +1,182 @@ +package io.github.polentino.redacted + +import scala.tools.nsc.backend.jvm.GenBCode +import scala.tools.nsc.plugins.PluginComponent +import scala.tools.nsc.transform.Transform +import scala.util.Success +import scala.tools.nsc.Global + +class RedactedPluginComponent(val global: Global) extends PluginComponent with Transform { + + override val phaseName: String = "patch-tostring-component" + + override val runsAfter: List[String] = List("parser") + + override val runsRightAfter: Option[String] = Some("parser") + + import global._ + + override protected def newTransformer(unit: CompilationUnit): Transformer = ToStringMaskerTransformer + + private object ToStringMaskerTransformer extends Transformer { + + private val TO_STRING_NAME = "toString" + private val redactedTypeName = TypeName("redacted") + + override def transform(tree: Tree): Tree = { + val transformedTree = super.transform(tree) + validate(transformedTree) match { + case None => transformedTree + case Some(validatedClassDef) => + val maybePatchedClassDef = for { + newToStringBody <- createToStringBody(validatedClassDef) + .withLog(s"couldn't create a valid toString body for ${validatedClassDef.name.decode}") + + newToStringMethod <- buildToStringMethod(newToStringBody) + .withLog(s"couldn't create a valid toString body for ${validatedClassDef.name.decode}") + + patchedClassDef <- patchCaseClass(validatedClassDef, newToStringMethod) + .withLog(s"couldn't create a valid toString body for ${validatedClassDef.name.decode}") + + } yield patchedClassDef + + maybePatchedClassDef match { + case Some(patchedClassDef) => patchedClassDef + case None => + reporter.warning( + tree.pos, + s""" + |Dang, couldn't patch properly ${tree.symbol.nameString} :( + |If you believe this is an error: please report the issue, along with a minimum reproducible example, + |at the following link: https://github.com/polentino/redacted/issues/new . + | + |Thank you 🙏 + |""".stripMargin + ) + tree + } + } + } + + /** Utility method that ensures the current tree being inspected is a case class with at least one parameter + * annotated with `@redacted`. + * @param tree + * the tree to be checked + * @return + * an option containing the validated `ClassDef`, or `None` + */ + private def validate(tree: Tree): Option[global.ClassDef] = for { + caseClassType <- validateTypeDef(tree) + _ <- getRedactedFields(caseClassType) + } yield caseClassType + + /** Utility method that checks whether the current tree being inspected corresponds to a case class. + * @param tree + * the tree to be checked + * @return + * an option containing the validated `ClassDef`, or `None` + */ + private def validateTypeDef(tree: Tree): Option[ClassDef] = tree match { + case classDef: ClassDef if classDef.mods.isCase => Some(classDef) + case _ => None + } + + /** Utility method that returns all ctor fields annotated with `@redacted` + * @param classDef + * the ClassDef to be checked + * @return + * an Option with the list of all params marked with `@redacted`, or `None` otherwise + */ + private def getRedactedFields(classDef: ClassDef): Option[List[ValDef]] = + classDef.impl.body.collectFirst { + case d: DefDef if d.name.decode == GenBCode.INSTANCE_CONSTRUCTOR_NAME => + d.vparamss.headOption.fold(List.empty[ValDef])(v => v.filter(_.mods.hasAnnotationNamed(redactedTypeName))) + } + + /** Utility method to generate a new `toString` definition based on the parameters marked with `@redacted`. + * @param classDef + * the ClassDef for which we need a dedicated `toString` method + * @return + * the body of the new `toString` method + */ + private def createToStringBody(classDef: ClassDef): scala.util.Try[Tree] = scala.util.Try { + val className = classDef.name.decode + val memberNames = getAllFields(classDef) + val classPrefix = (className + "(").toConstantLiteral + val classSuffix = ")".toConstantLiteral + val commaSymbol = ",".toConstantLiteral + val asterisksSymbol = "***".toConstantLiteral + val concatOperator = TermName("$plus") + + val fragments: List[Tree] = memberNames.map(m => + if (m.mods.hasAnnotationNamed(redactedTypeName)) asterisksSymbol + else Apply(Select(Ident(m.name), TO_STRING_NAME), Nil)) + + def buildToStringTree(fragments: List[Tree]): Tree = { + + def concatAll(l: List[Tree]): List[Tree] = l match { + case Nil => Nil + case head :: Nil => List(head) + case head :: tail => List(head, commaSymbol) ++ concatAll(tail) + } + + val res = concatAll(fragments).fold(classPrefix) { case (accumulator, fragment) => + Apply(Select(accumulator, concatOperator), List(fragment)) + } + Apply(Select(res, concatOperator), List(classSuffix)) + } + + buildToStringTree(fragments) + } + + /** Returns all the fields in a case class ctor. + * @param classDef + * the `ClassDef` for which we want to get all if ctor field + * @return + * a list of all the `ValDef` + */ + private def getAllFields(classDef: ClassDef): List[ValDef] = + classDef.impl.body.collectFirst { + case d: DefDef if d.name.decode == GenBCode.INSTANCE_CONSTRUCTOR_NAME => d.vparamss.headOption.getOrElse(Nil) + }.getOrElse(Nil) + + /** Build a new `toString` method definition containing the body passed as parameter. + * @param body + * the body of the newly created `toString` method + * @return + * the whole `toString` method definition + */ + private def buildToStringMethod(body: Tree): scala.util.Try[DefDef] = scala.util.Try { + DefDef(Modifiers(Flag.OVERRIDE), TermName(TO_STRING_NAME), Nil, Nil, TypeTree(), body) + } + + /** Utility method that adds a new method definition to an existing `ClassDef` body. + * @param classDef + * the class that needs to be patched + * @param newToStringMethod + * the new method that will be included in the `ClassDef` passed as first parameter + * @return + * the patched `ClassDef` + */ + private def patchCaseClass(classDef: ClassDef, newToStringMethod: Tree): scala.util.Try[ClassDef] = + scala.util.Try { + val newBody = classDef.impl.body :+ newToStringMethod + val newImpl = classDef.impl.copy(body = newBody) + classDef.copy(impl = newImpl) + } + + // utility extension classes + + private implicit class AstOps(s: String) { + def toConstantLiteral: Literal = Literal(Constant(s)) + } + + private implicit class TryOps[Out](opt: scala.util.Try[Out]) { + + def withLog(message: String): Option[Out] = opt match { + case Success(value) => Some(value) + case _ => reporter.echo(message); None + } + } + } +} diff --git a/plugin/src/main/scala/io/github/polentino/redacted/RedactedPlugin.scala b/plugin/src/main/scala-3/io/github/polentino/redacted/RedactedPlugin.scala similarity index 88% rename from plugin/src/main/scala/io/github/polentino/redacted/RedactedPlugin.scala rename to plugin/src/main/scala-3/io/github/polentino/redacted/RedactedPlugin.scala index b30240e..4bd9970 100644 --- a/plugin/src/main/scala/io/github/polentino/redacted/RedactedPlugin.scala +++ b/plugin/src/main/scala-3/io/github/polentino/redacted/RedactedPlugin.scala @@ -7,7 +7,7 @@ import io.github.polentino.redacted.phases._ class RedactedPlugin extends StandardPlugin { override def init(options: List[String]): List[PluginPhase] = List(PatchToString()) - override def name: String = "Redacted" + override def name: String = "redacted-plugin" override def description: String = "Plugin to prevent leaking sensitive data when logging case classes" } diff --git a/plugin/src/main/scala/io/github/polentino/redacted/helpers/AstOps.scala b/plugin/src/main/scala-3/io/github/polentino/redacted/helpers/AstOps.scala similarity index 80% rename from plugin/src/main/scala/io/github/polentino/redacted/helpers/AstOps.scala rename to plugin/src/main/scala-3/io/github/polentino/redacted/helpers/AstOps.scala index 3e50491..32bcb55 100644 --- a/plugin/src/main/scala/io/github/polentino/redacted/helpers/AstOps.scala +++ b/plugin/src/main/scala-3/io/github/polentino/redacted/helpers/AstOps.scala @@ -20,8 +20,10 @@ object AstOps { def redactedFields: List[String] = { val redactedType = redactedSymbol - symbol.primaryConstructor.paramSymss.flatten.collect { - case s if s.annotations.exists(_.matches(redactedType)) => s.name.toString + symbol.primaryConstructor.paramSymss.headOption.fold(List.empty[String]) { params => + params + .filter(_.annotations.exists(_.matches(redactedType))) + .map(_.name.toString) } } } diff --git a/plugin/src/main/scala/io/github/polentino/redacted/helpers/PluginOps.scala b/plugin/src/main/scala-3/io/github/polentino/redacted/helpers/PluginOps.scala similarity index 99% rename from plugin/src/main/scala/io/github/polentino/redacted/helpers/PluginOps.scala rename to plugin/src/main/scala-3/io/github/polentino/redacted/helpers/PluginOps.scala index 9fb0e6a..3c1c42e 100644 --- a/plugin/src/main/scala/io/github/polentino/redacted/helpers/PluginOps.scala +++ b/plugin/src/main/scala-3/io/github/polentino/redacted/helpers/PluginOps.scala @@ -117,7 +117,7 @@ object PluginOps { */ def createToStringBody(tree: tpd.TypeDef)(using Context): Try[tpd.Tree] = Try { val className = tree.name.toString - val memberNames = tree.symbol.primaryConstructor.paramSymss.flatten + val memberNames = tree.symbol.primaryConstructor.paramSymss.headOption.getOrElse(Nil) val annotationSymbol = redactedSymbol val classPrefix = (className + "(").toConstantLiteral val classSuffix = ")".toConstantLiteral diff --git a/plugin/src/main/scala/io/github/polentino/redacted/phases/PatchToString.scala b/plugin/src/main/scala-3/io/github/polentino/redacted/phases/PatchToString.scala similarity index 77% rename from plugin/src/main/scala/io/github/polentino/redacted/phases/PatchToString.scala rename to plugin/src/main/scala-3/io/github/polentino/redacted/phases/PatchToString.scala index ff11f25..26e0868 100644 --- a/plugin/src/main/scala/io/github/polentino/redacted/phases/PatchToString.scala +++ b/plugin/src/main/scala-3/io/github/polentino/redacted/phases/PatchToString.scala @@ -22,7 +22,6 @@ final case class PatchToString() extends PluginPhase { case None => tree case Some(validatedTree) => val maybeNewTypeDef = for { - template <- getTreeTemplate(validatedTree) .withLog(s"can't extract proper `tpd.Template` from ${tree.name}") @@ -36,23 +35,24 @@ final case class PatchToString() extends PluginPhase { .withLog(s"couldn't patch ${tree.name} template into ${tree.name} typedef") } yield result - maybeNewTypeDef match + maybeNewTypeDef match { case Some(newTypeDef) => newTypeDef case None => report.warning( s""" - |Dang, couldn't patch properly ${tree.name} :( - |If you believe this is an error: please report the issue, along with a minimum reproducible example, - |at the following link: https://github.com/polentino/redacted/issues/new . - | - |Thank you 🙏 - |""".stripMargin, + |Dang, couldn't patch properly ${tree.name} :( + |If you believe this is an error: please report the issue, along with a minimum reproducible example, + |at the following link: https://github.com/polentino/redacted/issues/new . + | + |Thank you 🙏 + |""".stripMargin, tree.srcPos ) tree + } } } object PatchToString { - final val name: String = "PatchToString" + final val name: String = "patch-tostring-phase" } diff --git a/tests/src/test/scala/io/github/polentino/redacted/RedactedSpec.scala b/tests/src/test/scala/io/github/polentino/redacted/RedactedSpec.scala index 19bf6b5..0c582c8 100644 --- a/tests/src/test/scala/io/github/polentino/redacted/RedactedSpec.scala +++ b/tests/src/test/scala/io/github/polentino/redacted/RedactedSpec.scala @@ -1,6 +1,6 @@ package io.github.polentino.redacted -import org.scalatest.Checkpoints.* +import org.scalatest.Checkpoints._ import org.scalatest.flatspec.AnyFlatSpec import io.github.polentino.redacted.RedactionWithNestedCaseClass.Inner import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks @@ -71,6 +71,48 @@ class RedactedSpec extends AnyFlatSpec with ScalaCheckPropertyChecks { } } + it should "not confuse the parameter of a method with the parameter of the main ctor" in { + case class TestWrongAnnotationPlacement(name: String, age: Int) { + + /** WRONG! */ + def toUpper(@redacted name: String): String = name.toUpperCase() + } + + forAll { (name: String, age: Int) => + val expected = s"TestWrongAnnotationPlacement($name,$age)" + val testing = TestWrongAnnotationPlacement(name, age) + val implicitToString = s"$testing" + val explicitToString = testing.toString + + val cp = new Checkpoint + cp { assert(implicitToString == expected) } + cp { assert(explicitToString == expected) } + cp { + assert(testing.name == name && testing.age == age) + } + cp.reportAll() + } + } + + it should "ignore `@redacted` annotation on curried parameters" in { + case class Curried(age: Int, @redacted name: String)(@redacted email: String) + + forAll { (age: Int, name: String, email: String) => + val expected = s"Curried($age,***)" + val testing = Curried(age, name)(email) + val implicitToString = s"$testing" + val explicitToString = testing.toString + + val cp = new Checkpoint + cp { assert(implicitToString == expected) } + cp { assert(explicitToString == expected) } + cp { + assert(testing.age == age && testing.name == name) + } + cp.reportAll() + } + } + it should "work with nested case classes in case class" in { case class Inner(userId: String, @redacted balance: Int) case class Outer(inner: Inner) diff --git a/version.sbt b/version.sbt index eae5ef9..9283500 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "0.4.0" +ThisBuild / version := "0.5.0"