Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,42 @@
*/
package org.grails.plugins.codecs

import java.nio.charset.Charset
import java.nio.charset.StandardCharsets

import org.codehaus.groovy.runtime.NullObject

import org.apache.commons.codec.binary.Base64

import groovy.transform.CompileStatic

/**
* A codec that encodes and decodes Objects using Base64 encoding.
*
* @author Drew Varner
*/
@CompileStatic
class Base64CodecExtensionMethods {

static encodeAsBase64(theTarget) {
static Object encodeAsBase64(Object theTarget, Charset charset = StandardCharsets.UTF_8) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}

if (theTarget instanceof Byte[] || theTarget instanceof byte[]) {
return new String(Base64.encodeBase64(theTarget))
return new String(Base64.encodeBase64(DigestUtils.toByteArray(theTarget)), StandardCharsets.UTF_8)
Comment thread
jdaugherty marked this conversation as resolved.
}

return new String(Base64.encodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8)))
return new String(Base64.encodeBase64(theTarget.toString().getBytes(charset)), StandardCharsets.UTF_8)
}

static decodeBase64(theTarget) {
static Object decodeBase64(Object theTarget, Charset charset = StandardCharsets.UTF_8) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}

if (theTarget instanceof Byte[] || theTarget instanceof byte[]) {
return Base64.decodeBase64(theTarget)
return Base64.decodeBase64(DigestUtils.toByteArray(theTarget))
}

return Base64.decodeBase64(theTarget.toString().getBytes(StandardCharsets.UTF_8))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should take the opportunity to make UTF_8 an added method argument that's defaulted

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,89 @@
*/
package org.grails.plugins.codecs

import java.lang.reflect.Array
import java.nio.charset.StandardCharsets
import java.security.MessageDigest

import groovy.transform.CompileStatic
import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation

@CompileStatic
abstract class DigestUtils {

// Digest byte[], any list/array or string into a byte[]
static digest(String algorithm, data) {
static Object digest(String algorithm, Object data) {
if (data == null) {
return null
}

def md = MessageDigest.getInstance(algorithm)
def src
if (data instanceof Byte[] || data instanceof byte[]) {
src = data
MessageDigest md = MessageDigest.getInstance(algorithm)
byte[] src = toByteArray(data)
md.update(src) // This probably needs to use the thread's Locale encoding
return md.digest()
}

protected static byte[] toByteArray(Object data) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: protected reads as "for subclasses", but the actual consumers (Base64CodecExtensionMethods, HexCodecExtensionMethods) rely on protected's package-access side effect. @PackageScope (or a brief comment) would make the intent clearer.

if (data instanceof byte[]) {
return (byte[]) data
}
else if (data instanceof List || data.getClass().isArray()) {
src = new byte[data.size()]
data.eachWithIndex { v, i -> src[i] = v }
if (data instanceof Byte[]) {
return toByteArrayFromWrapper((Byte[]) data)
}
else {
src = data.toString().getBytes(StandardCharsets.UTF_8)
if (data instanceof Iterable) {
return toByteArrayFromIterable((Iterable<?>) data)
}
md.update(src) // This probably needs to use the thread's Locale encoding
return md.digest()
if (data instanceof Iterator) {
return toByteArrayFromIterator((Iterator<?>) data)
}
if (data.getClass().isArray()) {
return toByteArrayFromArray(data, Array.getLength(data))
}

return data.toString().getBytes(StandardCharsets.UTF_8)
}

private static byte[] toByteArrayFromWrapper(Byte[] data) {
byte[] result = new byte[data.length]
for (int i = 0; i < data.length; i++) {
result[i] = data[i].byteValue()
}
return result
}

private static byte[] toByteArrayFromIterable(Iterable<?> data) {
List<Byte> bytes = new ArrayList<Byte>()
for (Object value : data) {
bytes.add(toByte(value))
}
return toByteArrayFromList(bytes)
}

private static byte[] toByteArrayFromIterator(Iterator<?> data) {
List<Byte> bytes = new ArrayList<Byte>()
while (data.hasNext()) {
bytes.add(toByte(data.next()))
}
return toByteArrayFromList(bytes)
}

private static byte[] toByteArrayFromList(List<Byte> data) {
byte[] result = new byte[data.size()]
for (int i = 0; i < data.size(); i++) {
result[i] = data.get(i)
}
return result
}

private static byte[] toByteArrayFromArray(Object data, int length) {
byte[] result = new byte[length]
for (int i = 0; i < length; i++) {
result[i] = toByte(Array.get(data, i))
}
return result
}

private static byte toByte(Object value) {
DefaultTypeTransformation.castToNumber(value).byteValue()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,48 @@ package org.grails.plugins.codecs
import java.nio.charset.StandardCharsets

import org.codehaus.groovy.runtime.NullObject
import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation

import groovy.transform.CompileStatic

@CompileStatic
class HexCodecExtensionMethods {

static HEXDIGITS = '0123456789abcdef'
static Object HEXDIGITS = '0123456789abcdef'

// Expects an array/list of numbers
static encodeAsHex(theTarget) {
static Object encodeAsHex(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}

def result = new StringBuilder()
if (theTarget instanceof String) {
theTarget = theTarget.getBytes(StandardCharsets.UTF_8)
}
theTarget.each() {
result << HexCodecExtensionMethods.HEXDIGITS[(it & 0xF0) >> 4]
result << HexCodecExtensionMethods.HEXDIGITS[it & 0x0F]
byte[] bytes = theTarget instanceof String ? ((String) theTarget).getBytes(StandardCharsets.UTF_8) : DigestUtils.toByteArray(theTarget)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change for iterable targets that aren't Lists or arrays (e.g. a Set<Integer> or an Iterator): the old code element-wise hex-encoded anything via theTarget.each { ... }, but DigestUtils.toByteArray only special-cases byte[]/Byte[]/List/arrays and otherwise falls through to toString().getBytes(UTF_8) — so a Set of numbers now silently encodes its string representation instead of its elements. Consider handling Collection/Iterable in toByteArray (or at least calling this out in the compatibility notes, since the failure mode is silent wrong output rather than an exception).

StringBuilder result = new StringBuilder(bytes.length * 2)
String hexDigits = (String) HEXDIGITS
for (byte value : bytes) {
int unsignedValue = value & 0xFF
result.append(hexDigits.charAt((unsignedValue & 0xF0) >> 4))
result.append(hexDigits.charAt(unsignedValue & 0x0F))
}
return result.toString()
}

static decodeHex(theTarget) {
if (!theTarget) return null
static Object decodeHex(Object theTarget) {
if (theTarget instanceof CharSequence && theTarget.length() == 0) return new byte[0]
if (!DefaultTypeTransformation.castToBoolean(theTarget)) return null

def output = []

def str = theTarget.toString().toLowerCase()
if (str.size() % 2) {
String str = theTarget.toString().toLowerCase()
if (str.length() % 2 != 0) {
throw new UnsupportedOperationException('Decode of hex strings requires strings of even length')
}

def currentByte
str.eachWithIndex { val, idx ->
if (!(idx % 2)) {
currentByte = HEXDIGITS.indexOf(val) << 4
}
else {
output << (currentByte | HEXDIGITS.indexOf(val))
currentByte = 0
}
byte[] result = new byte[str.length() >> 1]
String hexDigits = (String) HEXDIGITS
for (int i = 0; i < str.length(); i += 2) {
int high = hexDigits.indexOf((int) str.charAt(i))
int low = hexDigits.indexOf((int) str.charAt(i + 1))
result[i >> 1] = (byte) ((high << 4) | low)
}

def result = new byte[output.size()]
output.eachWithIndex { v, i -> result[i] = v }
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@ package org.grails.plugins.codecs

import org.codehaus.groovy.runtime.NullObject

import groovy.transform.CompileStatic

@CompileStatic
class MD5BytesCodecExtensionMethods {

// Returns the byte[] of the digest, taken from UTF-8 of the string representation
// or the raw data coerced to bytes
static encodeAsMD5Bytes(theTarget) {
static Object encodeAsMD5Bytes(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}
DigestUtils.digest('MD5', theTarget)
}

static decodeMD5Bytes(theTarget) {
static Object decodeMD5Bytes(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode MD5 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@
*/
package org.grails.plugins.codecs

import groovy.transform.CompileStatic

@CompileStatic
class MD5CodecExtensionMethods {

// Returns the byte[] of the digest, taken from UTF-8 of the string representation
// or the raw data coerced to bytes
static encodeAsMD5(theTarget) {
theTarget.encodeAsMD5Bytes()?.encodeAsHex()
static Object encodeAsMD5(Object theTarget) {
byte[] digest = (byte[]) MD5BytesCodecExtensionMethods.encodeAsMD5Bytes(theTarget)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming this is intended: the old theTarget.encodeAsMD5Bytes()?.encodeAsHex() dispatched dynamically, so a runtime metaclass override of encodeAsMD5Bytes (e.g. an application-supplied codec replacing the default) would also change what encodeAsMD5 produced. The direct static call hardwires the default implementation. Same applies to the SHA-1/SHA-256 variants. Probably fine — it's the whole point of removing dynamic dispatch — but worth a note in the compatibility section.

return digest == null ? null : HexCodecExtensionMethods.encodeAsHex(digest)
}

static decodeMD5(theTarget) {
static Object decodeMD5(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode MD5 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ package org.grails.plugins.codecs

import org.codehaus.groovy.runtime.NullObject

import groovy.transform.CompileStatic

@CompileStatic
class SHA1BytesCodecExtensionMethods {

// Returns the byte[] of the digest
static encodeAsSHA1Bytes(theTarget) {
static Object encodeAsSHA1Bytes(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}
DigestUtils.digest('SHA-1', theTarget)
}

static decodeSHA1Bytes(theTarget) {
static Object decodeSHA1Bytes(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode SHA-1 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ package org.grails.plugins.codecs

import org.codehaus.groovy.runtime.NullObject

import groovy.transform.CompileStatic

@CompileStatic
class SHA1CodecExtensionMethods {

// Returns the byte[] of the digest
static encodeAsSHA1(theTarget) {
static Object encodeAsSHA1(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}
theTarget.encodeAsSHA1Bytes().encodeAsHex()
return HexCodecExtensionMethods.encodeAsHex(SHA1BytesCodecExtensionMethods.encodeAsSHA1Bytes(theTarget))
}

static decodeSHA1(theTarget) {
static Object decodeSHA1(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode SHA-1 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ package org.grails.plugins.codecs

import org.codehaus.groovy.runtime.NullObject

import groovy.transform.CompileStatic

@CompileStatic
class SHA256BytesCodecExtensionMethods {

// Returns the byte[] of the digest
static encodeAsSHA256Bytes(theTarget) {
static Object encodeAsSHA256Bytes(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}
DigestUtils.digest('SHA-256', theTarget)
}

static decodeSHA256Bytes(theTarget) {
static Object decodeSHA256Bytes(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode SHA-256 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,20 @@ package org.grails.plugins.codecs

import org.codehaus.groovy.runtime.NullObject

import groovy.transform.CompileStatic

@CompileStatic
class SHA256CodecExtensionMethods extends DigestUtils {

// Returns the byte[] of the digest
static encodeAsSHA256(theTarget) {
static Object encodeAsSHA256(Object theTarget) {
if (theTarget == null || theTarget instanceof NullObject) {
return null
}
theTarget.encodeAsSHA256Bytes()?.encodeAsHex()
return HexCodecExtensionMethods.encodeAsHex(SHA256BytesCodecExtensionMethods.encodeAsSHA256Bytes(theTarget))
}

static decodeSHA256(theTarget) {
static Object decodeSHA256(Object theTarget) {
throw new UnsupportedOperationException('Cannot decode SHA-256 hashes')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

package org.grails.web.codecs

import java.nio.charset.StandardCharsets

import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

Expand Down Expand Up @@ -79,4 +81,11 @@ class Base64CodecTests {
assertEquals "dGVzdA==", "test".encodeAsBase64()
assertEquals "test", new String("dGVzdA==".decodeBase64())
}

@Test
void testEncodeDecodeAsBase64WithCharset() {
assertEquals '6Q==', 'é'.encodeAsBase64(StandardCharsets.ISO_8859_1)
assertEquals 'é', new String('6Q=='.decodeBase64(StandardCharsets.ISO_8859_1), StandardCharsets.ISO_8859_1)
assertEquals '/v///g==', '￾'.encodeAsBase64(StandardCharsets.UTF_16)
}
}
Loading
Loading