Skip to content

Commit

Permalink
Support Unredacted properties and supertype based redaction (#210)
Browse files Browse the repository at this point in the history
* Support Unredacted properties and supertype based redaction

* Update compiler plugin tests

* Review feedback

* Review feedback 2

* Update FIR declaration checker

* Add tests for sealed/abstract redaction

* remove wildcard import
  • Loading branch information
DrewCarlson authored Apr 23, 2024
1 parent ad6ba72 commit a466998
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 19 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ redacted {
// classes by '.', e.g. "kotlin/Map.Entry"
redactedAnnotation = "dev/zacsweers/redacted/annotations/Redacted" // Default
// Define a custom unredacted annotation.
unredactedAnnotation = "dev/zacsweers/redacted/annotations/Unredacted" // Default
// Define whether or not this is enabled. Useful if you want to gate this behind a dynamic
// build configuration.
enabled = true // Default
Expand All @@ -85,6 +88,42 @@ multiplatform and supports all common JVM, JS, and native targets.
but usage in newer versions of kotlinc are not guaranteed to be stable.
- IDE support is not currently possible. See [#8](https://github.com/ZacSweers/redacted-compiler-plugin/issues/8).


## Advanced Usage

In situations where it is desirable to redact everything and opt-out on certain properties,
two options are provided:

**Class redaction**

For one-off classes that may contain a large number of fields that should be redacted, you can augment the `@Redacted`
class behavior:

```kotlin
@Redacted
data class User(@Unredacted val name: String, val phoneNumber: String, val ssn: String)
```

```
User(name=Bob, phoneNumber=██, ssn=██)
```

**Supertype redaction**

For situations where you need to enforce that an API only accepts redacted inputs, you can apply `@Redacted` to a
parent interface.

```kotlin
@Redacted
interface RedactedObject

data class User(@Unredacted val name: String, val phoneNumber: String, val ssn: String) : RedactedObject
```

```
User(name=Bob, phoneNumber=██, ssn=██)
```

License
-------

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
public abstract interface annotation class dev/zacsweers/redacted/annotations/Redacted : java/lang/annotation/Annotation {
}

public abstract interface annotation class dev/zacsweers/redacted/annotations/Unredacted : java/lang/annotation/Annotation {
}

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import kotlin.annotation.AnnotationTarget.PROPERTY

/**
* An annotation to indicate that a particular property or class should be redacted in `toString()`
* implementations.
* implementations. When applied to an interface, subtypes will behave as if the class is annotated
* with `@Redacted`.
*
* For properties, each individual property will be redacted. Example: `User(name=Bob,
* phoneNumber=██)`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2024 Zac Sweers
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.zacsweers.redacted.annotations

import kotlin.annotation.AnnotationRetention.BINARY
import kotlin.annotation.AnnotationTarget.PROPERTY

/**
* An annotation to indicate that a particular property should NOT be redacted in `toString()`
* implementations.
*
* This annotation can be applied to a property in any class that applies `@Redacted` or implements
* an interface that applies `@Redacted`.
*
* Example:
* ```
* @Redacted
* data class User(@Unredacted val name: String, val phoneNumber: String)
*
* println(user) // User(name = "Bob", phoneNumber = "██")
* ```
*/
@Retention(BINARY) @Target(PROPERTY) public annotation class Unredacted
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property

internal const val DEFAULT_ANNOTATION = "dev/zacsweers/redacted/annotations/Redacted"
internal const val DEFAULT_UNREDACTED_ANNOTATION = "dev/zacsweers/redacted/annotations/Unredacted"

public abstract class RedactedPluginExtension @Inject constructor(objects: ObjectFactory) {
/**
Expand All @@ -32,6 +33,9 @@ public abstract class RedactedPluginExtension @Inject constructor(objects: Objec
public val redactedAnnotation: Property<String> =
objects.property(String::class.java).convention(DEFAULT_ANNOTATION)

public val unredactedAnnotation: Property<String> =
objects.property(String::class.java).convention(DEFAULT_UNREDACTED_ANNOTATION)

public val enabled: Property<Boolean> =
objects.property(Boolean::class.javaObjectType).convention(true)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class RedactedGradleSubplugin : KotlinCompilerPluginSupportPlugin {
val project = kotlinCompilation.target.project
val extension = project.extensions.getByType(RedactedPluginExtension::class.java)
val annotation = extension.redactedAnnotation
val unredactedAnnotation = extension.unredactedAnnotation

// Default annotation is used, so add it as a dependency
// Note only multiplatform, jvm/android, and js are supported. Anyone else is on their own.
Expand All @@ -62,6 +63,7 @@ public class RedactedGradleSubplugin : KotlinCompilerPluginSupportPlugin {
SubpluginOption(key = "enabled", value = enabled.toString()),
SubpluginOption(key = "replacementString", value = extension.replacementString.get()),
SubpluginOption(key = "redactedAnnotation", value = annotation.get()),
SubpluginOption(key = "unredactedAnnotation", value = unredactedAnnotation.get()),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ internal val KEY_REDACTED_ANNOTATION =
CompilerConfigurationKey<String>(
"The redacted marker annotation (i.e. com/example/Redacted) to look for when redacting"
)
internal val KEY_UNREDACTED_ANNOTATION =
CompilerConfigurationKey<String>(
"The unredacted marker annotation (i.e. com/example/Unredacted) to look for when redacting"
)

@OptIn(ExperimentalCompilerApi::class)
@AutoService(CommandLineProcessor::class)
Expand Down Expand Up @@ -63,12 +67,26 @@ public class RedactedCommandLineProcessor : CommandLineProcessor {
required = true,
allowMultipleOccurrences = false,
)

val OPTION_UNREDACTED_ANNOTATION =
CliOption(
optionName = "unredactedAnnotation",
valueDescription = "String",
description = KEY_UNREDACTED_ANNOTATION.toString(),
required = true,
allowMultipleOccurrences = false,
)
}

override val pluginId: String = "dev.zacsweers.redacted.compiler"

override val pluginOptions: Collection<AbstractCliOption> =
listOf(OPTION_ENABLED, OPTION_REPLACEMENT_STRING, OPTION_REDACTED_ANNOTATION)
listOf(
OPTION_ENABLED,
OPTION_REPLACEMENT_STRING,
OPTION_REDACTED_ANNOTATION,
OPTION_UNREDACTED_ANNOTATION,
)

override fun processOption(
option: AbstractCliOption,
Expand All @@ -79,6 +97,7 @@ public class RedactedCommandLineProcessor : CommandLineProcessor {
"enabled" -> configuration.put(KEY_ENABLED, value.toBoolean())
"replacementString" -> configuration.put(KEY_REPLACEMENT_STRING, value)
"redactedAnnotation" -> configuration.put(KEY_REDACTED_ANNOTATION, value)
"unredactedAnnotation" -> configuration.put(KEY_UNREDACTED_ANNOTATION, value)
else -> error("Unknown plugin option: ${option.optionName}")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ internal class RedactedIrGenerationExtension(
private val messageCollector: MessageCollector,
private val replacementString: String,
private val redactedAnnotationName: FqName,
private val unredactedAnnotationName: FqName,
) : IrGenerationExtension {

override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
val redactedTransformer =
RedactedIrVisitor(pluginContext, redactedAnnotationName, replacementString, messageCollector)
RedactedIrVisitor(
pluginContext,
redactedAnnotationName,
unredactedAnnotationName,
replacementString,
messageCollector,
)
moduleFragment.transform(redactedTransformer, null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package dev.zacsweers.redacted.compiler

import kotlin.LazyThreadSafetyMode.NONE
import org.jetbrains.kotlin.backend.common.IrElementTransformerVoidWithContext
import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
Expand All @@ -24,6 +25,7 @@ import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.IrStatement
import org.jetbrains.kotlin.ir.backend.js.utils.isInstantiableEnum
import org.jetbrains.kotlin.ir.builders.IrBlockBodyBuilder
import org.jetbrains.kotlin.ir.builders.irBlockBody
import org.jetbrains.kotlin.ir.builders.irCall
Expand All @@ -44,7 +46,11 @@ import org.jetbrains.kotlin.ir.types.isArray
import org.jetbrains.kotlin.ir.types.isString
import org.jetbrains.kotlin.ir.util.SYNTHETIC_OFFSET
import org.jetbrains.kotlin.ir.util.file
import org.jetbrains.kotlin.ir.util.getAllSuperclasses
import org.jetbrains.kotlin.ir.util.hasAnnotation
import org.jetbrains.kotlin.ir.util.isEnumClass
import org.jetbrains.kotlin.ir.util.isEnumEntry
import org.jetbrains.kotlin.ir.util.isFinalClass
import org.jetbrains.kotlin.ir.util.isObject
import org.jetbrains.kotlin.ir.util.isPrimitiveArray
import org.jetbrains.kotlin.ir.util.primaryConstructor
Expand All @@ -57,13 +63,15 @@ internal const val LOG_PREFIX = "*** REDACTED (IR):"
internal class RedactedIrVisitor(
private val pluginContext: IrPluginContext,
private val redactedAnnotation: FqName,
private val unredactedAnnotation: FqName,
private val replacementString: String,
private val messageCollector: MessageCollector,
) : IrElementTransformerVoidWithContext() {

private class Property(
val ir: IrProperty,
val isRedacted: Boolean,
val isUnredacted: Boolean,
val parameter: IrValueParameter,
)

Expand All @@ -79,23 +87,43 @@ internal class RedactedIrVisitor(

val properties = mutableListOf<Property>()
val classIsRedacted = declarationParent.hasAnnotation(redactedAnnotation)
val supertypeIsRedacted by
lazy(NONE) {
declarationParent.getAllSuperclasses().any { it.hasAnnotation(redactedAnnotation) }
}
var anyRedacted = false
var anyUnredacted = false
for (prop in declarationParent.properties) {
val parameter = constructorParameters[prop.name.asString()] ?: continue
val isRedacted = prop.isRedacted()
val isRedacted = prop.isRedacted
val isUnredacted = prop.isUnredacted
if (isRedacted) {
anyRedacted = true
}
properties += Property(prop, isRedacted, parameter)
if (isUnredacted) {
anyUnredacted = true
}
properties += Property(prop, isRedacted, isUnredacted, parameter)
}
if (classIsRedacted || anyRedacted) {

if (classIsRedacted || supertypeIsRedacted || anyRedacted) {
if (declaration.origin == IrDeclarationOrigin.DEFINED) {
declaration.reportError(
"@Redacted is only supported on data or value classes that do *not* have a custom toString() function. Please remove the function or remove the @Redacted annotations."
)
return super.visitFunctionNew(declaration)
}
if (!declarationParent.isData && !declarationParent.isValue) {
if (
declarationParent.isInstantiableEnum ||
declarationParent.isEnumClass ||
declarationParent.isEnumEntry
) {
declarationParent.reportError("@Redacted does not support enum classes or entries!")
return super.visitFunctionNew(declaration)
}
if (
declarationParent.isFinalClass && !declarationParent.isData && !declarationParent.isValue
) {
declarationParent.reportError("@Redacted is only supported on data or value classes!")
return super.visitFunctionNew(declaration)
}
Expand All @@ -109,13 +137,19 @@ internal class RedactedIrVisitor(
declarationParent.reportError("@Redacted is useless on object classes.")
return super.visitFunctionNew(declaration)
}
if (!(classIsRedacted xor anyRedacted)) {
if (anyUnredacted && (!classIsRedacted && !supertypeIsRedacted)) {
declarationParent.reportError(
"@Unredacted should only be applied to properties in a class or a supertype is marked @Redacted."
)
return super.visitFunctionNew(declaration)
}
if (!(classIsRedacted xor anyRedacted xor supertypeIsRedacted)) {
declarationParent.reportError(
"@Redacted should only be applied to the class or its properties, not both."
)
return super.visitFunctionNew(declaration)
}
declaration.convertToGeneratedToString(properties, classIsRedacted)
declaration.convertToGeneratedToString(properties, classIsRedacted, supertypeIsRedacted)
}
}

Expand All @@ -132,6 +166,7 @@ internal class RedactedIrVisitor(
private fun IrFunction.convertToGeneratedToString(
properties: List<Property>,
classIsRedacted: Boolean,
supertypeIsRedacted: Boolean,
) {
val parent = parent as IrClass

Expand All @@ -144,6 +179,7 @@ internal class RedactedIrVisitor(
irFunction = this@convertToGeneratedToString,
irProperties = properties,
classIsRedacted = classIsRedacted,
supertypeIsRedacted = supertypeIsRedacted,
)
}

Expand All @@ -157,9 +193,11 @@ internal class RedactedIrVisitor(
}
}

private fun IrProperty.isRedacted(): Boolean {
return hasAnnotation(redactedAnnotation)
}
private val IrProperty.isRedacted: Boolean
get() = hasAnnotation(redactedAnnotation)

private val IrProperty.isUnredacted: Boolean
get() = hasAnnotation(unredactedAnnotation)

/**
* The actual body of the toString method. Copied from
Expand All @@ -171,19 +209,24 @@ internal class RedactedIrVisitor(
irFunction: IrFunction,
irProperties: List<Property>,
classIsRedacted: Boolean,
supertypeIsRedacted: Boolean,
) {
val irConcat = irConcat()
irConcat.addArgument(irString(irClass.name.asString() + "("))
if (classIsRedacted) {
val hasUnredactedProperties by lazy(NONE) { irProperties.any { it.isUnredacted } }
if (classIsRedacted && !hasUnredactedProperties) {
irConcat.addArgument(irString(replacementString))
} else {
var first = true
for (property in irProperties) {
if (!first) irConcat.addArgument(irString(", "))

irConcat.addArgument(irString(property.ir.name.asString() + "="))

if (property.isRedacted) {
val redactProperty =
property.isRedacted ||
(classIsRedacted && !property.isUnredacted) ||
(supertypeIsRedacted && !property.isUnredacted)
if (redactProperty) {
irConcat.addArgument(irString(replacementString))
} else {
val irPropertyValue = irGetField(receiver(irFunction), property.ir.backingField!!)
Expand Down
Loading

0 comments on commit a466998

Please sign in to comment.