diff --git a/pom.xml b/pom.xml index 09a12fb..acd1ad5 100644 --- a/pom.xml +++ b/pom.xml @@ -157,7 +157,7 @@ org.slf4j slf4j-api - 1.7.26 + 2.0.16 true diff --git a/src/main/java/org/codelibs/core/collection/ArrayMap.java b/src/main/java/org/codelibs/core/collection/ArrayMap.java index fc0da7f..62b8839 100644 --- a/src/main/java/org/codelibs/core/collection/ArrayMap.java +++ b/src/main/java/org/codelibs/core/collection/ArrayMap.java @@ -657,7 +657,7 @@ public V getValue() { @Override public V setValue(final V value) { - final V oldValue = value; + final V oldValue = this.value; this.value = value; return oldValue; } diff --git a/src/main/java/org/codelibs/core/collection/LruHashMap.java b/src/main/java/org/codelibs/core/collection/LruHashMap.java index f2dc726..0dc175b 100644 --- a/src/main/java/org/codelibs/core/collection/LruHashMap.java +++ b/src/main/java/org/codelibs/core/collection/LruHashMap.java @@ -21,6 +21,22 @@ /** * {@link HashMap} with an upper limit on the number of entries. When a new entry is added, the oldest entry is discarded using LRU if the limit is exceeded. + *

+ * Thread-Safety: This class is NOT thread-safe. + * It extends {@link LinkedHashMap} without synchronization. If multiple threads access + * an instance concurrently, and at least one thread modifies the map structurally, + * it must be synchronized externally. + *

+ *

+ * For thread-safe usage, wrap with {@link java.util.Collections#synchronizedMap(Map)}: + *

+ *
+ * Map<K, V> syncMap = Collections.synchronizedMap(new LruHashMap<>(100));
+ * 
+ *

+ * Alternatively, for high-concurrency scenarios, consider implementing a custom + * LRU cache using {@link java.util.concurrent.ConcurrentHashMap} with an eviction strategy. + *

* * @author koichik * @param the key type diff --git a/src/main/java/org/codelibs/core/crypto/CachedCipher.java b/src/main/java/org/codelibs/core/crypto/CachedCipher.java index 2929d66..7c31640 100644 --- a/src/main/java/org/codelibs/core/crypto/CachedCipher.java +++ b/src/main/java/org/codelibs/core/crypto/CachedCipher.java @@ -38,7 +38,51 @@ import org.codelibs.core.misc.Base64Util; /** - * A utility class for encrypting and decrypting data using a cached {@link Cipher} instance. + * A high-performance utility class for encrypting and decrypting data using cached {@link Cipher} instances. + *

+ * This class provides efficient encryption/decryption by pooling and reusing cipher instances, + * reducing the overhead of repeated cipher initialization. It supports both string-based keys + * and {@link Key} objects, with configurable algorithms and character encodings. + *

+ *

+ * Key Features: + *

+ * + *

+ * Security Considerations: + *

+ * + *

+ * Usage Example: + *

+ *
+ * CachedCipher cipher = new CachedCipher();
+ * cipher.setKey("mySecretKey");
+ *
+ * // Encrypt text
+ * String encrypted = cipher.encryptText("Hello World");
+ *
+ * // Decrypt text
+ * String decrypted = cipher.decryptText(encrypted);
+ *
+ * // For AES encryption
+ * CachedCipher aesCipher = new CachedCipher();
+ * aesCipher.setAlgorithm("AES");
+ * aesCipher.setTransformation("AES");
+ * aesCipher.setKey("0123456789abcdef"); // 16-byte key for AES-128
+ * 
+ * + * @author higa */ public class CachedCipher { @@ -50,17 +94,22 @@ public CachedCipher() { private static final String BLOWFISH = "Blowfish"; - private static final String RSA = "RSA"; + private static final String AES = "AES"; /** * The algorithm to use for the cipher. + * Default is Blowfish for backward compatibility. */ protected String algorithm = BLOWFISH; /** - * The transformation to use for the cipher. + * The transformation to use for the cipher when using Key objects. + * Default is Blowfish to match the algorithm default. + *

+ * Note: For better security, consider using "AES/GCM/NoPadding" with proper IV handling. + *

*/ - protected String transformation = RSA; + protected String transformation = BLOWFISH; /** * The key to use for encryption/decryption. @@ -89,7 +138,7 @@ public CachedCipher() { * the data to encrypt * @return the encrypted data */ - public byte[] encrypto(final byte[] data) { + public byte[] encrypt(final byte[] data) { final Cipher cipher = pollEncryptoCipher(); byte[] encrypted; try { @@ -104,6 +153,19 @@ public byte[] encrypto(final byte[] data) { return encrypted; } + /** + * Encrypts the given data. + * + * @param data + * the data to encrypt + * @return the encrypted data + * @deprecated Use {@link #encrypt(byte[])} instead. This method name contains a typo. + */ + @Deprecated + public byte[] encrypto(final byte[] data) { + return encrypt(data); + } + /** * Encrypts the given data with the specified key. * @@ -113,7 +175,7 @@ public byte[] encrypto(final byte[] data) { * the key to use for encryption * @return the encrypted data */ - public byte[] encrypto(final byte[] data, final Key key) { + public byte[] encrypt(final byte[] data, final Key key) { final Cipher cipher = pollEncryptoCipher(key); byte[] encrypted; try { @@ -128,6 +190,21 @@ public byte[] encrypto(final byte[] data, final Key key) { return encrypted; } + /** + * Encrypts the given data with the specified key. + * + * @param data + * the data to encrypt + * @param key + * the key to use for encryption + * @return the encrypted data + * @deprecated Use {@link #encrypt(byte[], Key)} instead. This method name contains a typo. + */ + @Deprecated + public byte[] encrypto(final byte[] data, final Key key) { + return encrypt(data, key); + } + /** * Encrypts the given text. * @@ -135,14 +212,27 @@ public byte[] encrypto(final byte[] data, final Key key) { * the text to encrypt * @return the encrypted text */ - public String encryptoText(final String text) { + public String encryptText(final String text) { try { - return Base64Util.encode(encrypto(text.getBytes(charsetName))); + return Base64Util.encode(encrypt(text.getBytes(charsetName))); } catch (final UnsupportedEncodingException e) { throw new UnsupportedEncodingRuntimeException(e); } } + /** + * Encrypts the given text. + * + * @param text + * the text to encrypt + * @return the encrypted text + * @deprecated Use {@link #encryptText(String)} instead. This method name contains a typo. + */ + @Deprecated + public String encryptoText(final String text) { + return encryptText(text); + } + /** * Decrypts the given data. * @@ -150,7 +240,7 @@ public String encryptoText(final String text) { * the data to decrypt * @return the decrypted data */ - public byte[] decrypto(final byte[] data) { + public byte[] decrypt(final byte[] data) { final Cipher cipher = pollDecryptoCipher(); byte[] decrypted; try { @@ -165,6 +255,19 @@ public byte[] decrypto(final byte[] data) { return decrypted; } + /** + * Decrypts the given data. + * + * @param data + * the data to decrypt + * @return the decrypted data + * @deprecated Use {@link #decrypt(byte[])} instead. This method name contains a typo. + */ + @Deprecated + public byte[] decrypto(final byte[] data) { + return decrypt(data); + } + /** * Decrypts the given data with the specified key. * @@ -174,7 +277,7 @@ public byte[] decrypto(final byte[] data) { * the key to use for decryption * @return the decrypted data */ - public byte[] decrypto(final byte[] data, final Key key) { + public byte[] decrypt(final byte[] data, final Key key) { final Cipher cipher = pollDecryptoCipher(key); byte[] decrypted; try { @@ -189,6 +292,21 @@ public byte[] decrypto(final byte[] data, final Key key) { return decrypted; } + /** + * Decrypts the given data with the specified key. + * + * @param data + * the data to decrypt + * @param key + * the key to use for decryption + * @return the decrypted data + * @deprecated Use {@link #decrypt(byte[], Key)} instead. This method name contains a typo. + */ + @Deprecated + public byte[] decrypto(final byte[] data, final Key key) { + return decrypt(data, key); + } + /** * Decrypts the given text. * @@ -196,14 +314,27 @@ public byte[] decrypto(final byte[] data, final Key key) { * the text to decrypt * @return the decrypted text */ - public String decryptoText(final String text) { + public String decryptText(final String text) { try { - return new String(decrypto(Base64Util.decode(text)), charsetName); + return new String(decrypt(Base64Util.decode(text)), charsetName); } catch (final UnsupportedEncodingException e) { throw new UnsupportedEncodingRuntimeException(e); } } + /** + * Decrypts the given text. + * + * @param text + * the text to decrypt + * @return the decrypted text + * @deprecated Use {@link #decryptText(String)} instead. This method name contains a typo. + */ + @Deprecated + public String decryptoText(final String text) { + return decryptText(text); + } + /** * Polls an encryption cipher from the queue, creating a new one if none are available. * @@ -212,8 +343,8 @@ public String decryptoText(final String text) { protected Cipher pollEncryptoCipher() { Cipher cipher = encryptoQueue.poll(); if (cipher == null) { - final SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), algorithm); try { + final SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(charsetName), algorithm); cipher = Cipher.getInstance(algorithm); cipher.init(Cipher.ENCRYPT_MODE, sksSpec); } catch (final InvalidKeyException e) { @@ -222,6 +353,8 @@ protected Cipher pollEncryptoCipher() { throw new NoSuchAlgorithmRuntimeException(e); } catch (final NoSuchPaddingException e) { throw new NoSuchPaddingRuntimeException(e); + } catch (final UnsupportedEncodingException e) { + throw new UnsupportedEncodingRuntimeException(e); } } return cipher; @@ -269,8 +402,8 @@ protected void offerEncryptoCipher(final Cipher cipher) { protected Cipher pollDecryptoCipher() { Cipher cipher = decryptoQueue.poll(); if (cipher == null) { - final SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(), algorithm); try { + final SecretKeySpec sksSpec = new SecretKeySpec(key.getBytes(charsetName), algorithm); cipher = Cipher.getInstance(algorithm); cipher.init(Cipher.DECRYPT_MODE, sksSpec); } catch (final InvalidKeyException e) { @@ -279,6 +412,8 @@ protected Cipher pollDecryptoCipher() { throw new NoSuchAlgorithmRuntimeException(e); } catch (final NoSuchPaddingException e) { throw new NoSuchPaddingRuntimeException(e); + } catch (final UnsupportedEncodingException e) { + throw new UnsupportedEncodingRuntimeException(e); } } return cipher; diff --git a/src/main/java/org/codelibs/core/io/FileUtil.java b/src/main/java/org/codelibs/core/io/FileUtil.java index 83a3bac..1eb67e3 100644 --- a/src/main/java/org/codelibs/core/io/FileUtil.java +++ b/src/main/java/org/codelibs/core/io/FileUtil.java @@ -30,6 +30,7 @@ import java.nio.channels.FileChannel; import java.nio.charset.Charset; import java.nio.file.Files; +import java.nio.file.Path; import org.codelibs.core.exception.IORuntimeException; import org.codelibs.core.net.URLUtil; @@ -38,6 +39,11 @@ /** * Utility class for handling {@link File}. + *

+ * SECURITY NOTE: When accepting file paths from untrusted sources, + * always validate them using {@link #isPathSafe(Path, Path)} to prevent path traversal attacks. + * Methods that accept path strings do not perform automatic validation to maintain backward compatibility. + *

* * @author higa */ @@ -58,6 +64,55 @@ protected FileUtil() { /** Max Buffer Size */ protected static final int MAX_BUF_SIZE = 10 * 1024 * 1024; // 10m + /** + * Validates that a given path is safe and does not attempt path traversal attacks. + *

+ * This method checks if the resolved absolute path starts with the allowed base directory, + * preventing access to files outside the intended directory through path traversal + * techniques like "../../../etc/passwd". + *

+ *

+ * Example usage: + *

+ *
+     * Path baseDir = Paths.get("/var/app/data");
+     * Path userPath = Paths.get(userInput);
+     * if (!FileUtil.isPathSafe(userPath, baseDir)) {
+     *     throw new SecurityException("Path traversal attempt detected");
+     * }
+     * 
+ * + * @param pathToCheck the path to validate (must not be {@literal null}) + * @param baseDirectory the base directory that the path must be within (must not be {@literal null}) + * @return true if the path is safe (within the base directory), false otherwise + */ + public static boolean isPathSafe(final Path pathToCheck, final Path baseDirectory) { + assertArgumentNotNull("pathToCheck", pathToCheck); + assertArgumentNotNull("baseDirectory", baseDirectory); + + final Path normalizedPath = pathToCheck.toAbsolutePath().normalize(); + final Path normalizedBase = baseDirectory.toAbsolutePath().normalize(); + return normalizedPath.startsWith(normalizedBase); + } + + /** + * Validates that a given file is safe and does not attempt path traversal attacks. + *

+ * This is a convenience method that converts File objects to Path and calls + * {@link #isPathSafe(Path, Path)}. + *

+ * + * @param fileToCheck the file to validate (must not be {@literal null}) + * @param baseDirectory the base directory that the file must be within (must not be {@literal null}) + * @return true if the file is safe (within the base directory), false otherwise + */ + public static boolean isPathSafe(final File fileToCheck, final File baseDirectory) { + assertArgumentNotNull("fileToCheck", fileToCheck); + assertArgumentNotNull("baseDirectory", baseDirectory); + + return isPathSafe(fileToCheck.toPath(), baseDirectory.toPath()); + } + /** * Returns the canonical path string for this abstract pathname. * @@ -92,18 +147,50 @@ public static URL toURL(final File file) { /** * Reads the contents of a file into a byte array and returns it. + *

+ * Note: This method loads the entire file into memory. + * For files larger than {@value #MAX_BUF_SIZE} bytes (10MB), an + * {@link IORuntimeException} will be thrown to prevent OutOfMemoryError. + * For large files, use streaming APIs instead. + *

* * @param file * The file. Must not be {@literal null}. * @return A byte array containing the contents of the file. + * @throws IORuntimeException if the file is larger than {@value #MAX_BUF_SIZE} bytes */ public static byte[] readBytes(final File file) { + return readBytes(file, MAX_BUF_SIZE); + } + + /** + * Reads the contents of a file into a byte array and returns it with a custom size limit. + *

+ * Note: This method loads the entire file into memory. + * An {@link IORuntimeException} will be thrown if the file exceeds the specified maxSize + * to prevent OutOfMemoryError. For large files, use streaming APIs instead. + *

+ * + * @param file + * The file. Must not be {@literal null}. + * @param maxSize + * The maximum file size in bytes that can be read. + * @return A byte array containing the contents of the file. + * @throws IORuntimeException if the file is larger than maxSize bytes + */ + public static byte[] readBytes(final File file, final long maxSize) { assertArgumentNotNull("file", file); final FileInputStream is = InputStreamUtil.create(file); try { final FileChannel channel = is.getChannel(); - final ByteBuffer buffer = ByteBuffer.allocate((int) ChannelUtil.size(channel)); + final long fileSize = ChannelUtil.size(channel); + + if (fileSize > maxSize) { + throw new IORuntimeException(new IOException("File too large: " + fileSize + " bytes (max: " + maxSize + " bytes). Use streaming APIs for large files.")); + } + + final ByteBuffer buffer = ByteBuffer.allocate((int) fileSize); ChannelUtil.read(channel, buffer); return buffer.array(); } finally { @@ -230,10 +317,15 @@ protected static String read(final Reader reader, final int initialCapacity) { while ((len = reader.read(buf, size, bufferSize - size)) != -1) { size += len; if (size == bufferSize) { - final char[] newBuf = new char[bufferSize + initialCapacity]; + // Enforce MAX_BUF_SIZE to prevent unbounded memory growth + final int newBufferSize = bufferSize + initialCapacity; + if (newBufferSize > MAX_BUF_SIZE) { + throw new IORuntimeException(new IOException("Content too large: exceeds maximum buffer size of " + MAX_BUF_SIZE + " bytes. Use streaming APIs for large content.")); + } + final char[] newBuf = new char[newBufferSize]; System.arraycopy(buf, 0, newBuf, 0, bufferSize); buf = newBuf; - bufferSize += initialCapacity; + bufferSize = newBufferSize; } } return new String(buf, 0, size); diff --git a/src/main/java/org/codelibs/core/io/SerializeUtil.java b/src/main/java/org/codelibs/core/io/SerializeUtil.java index b144813..0748c57 100644 --- a/src/main/java/org/codelibs/core/io/SerializeUtil.java +++ b/src/main/java/org/codelibs/core/io/SerializeUtil.java @@ -21,14 +21,26 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.ObjectInputFilter; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.util.Set; import org.codelibs.core.exception.ClassNotFoundRuntimeException; import org.codelibs.core.exception.IORuntimeException; /** - * Utility for serializing objects. + * Utility for serializing objects with security protections. + *

+ * This utility provides object serialization and deserialization with built-in + * security protections against deserialization attacks. By default, it uses an + * ObjectInputFilter to restrict which classes can be deserialized. + *

+ *

+ * The default filter allows common safe classes like primitives, arrays, String, + * Number types, collections, and classes in the org.codelibs package. For custom + * requirements, use the overloaded methods that accept a custom filter. + *

* * @author higa */ @@ -42,6 +54,52 @@ protected SerializeUtil() { private static final int BYTE_ARRAY_SIZE = 8 * 1024; + /** + * Default set of allowed class name patterns for deserialization. + * This helps prevent deserialization attacks by restricting which classes can be instantiated. + */ + private static final Set DEFAULT_ALLOWED_PATTERNS = Set.of( + "java.lang.*", + "java.util.*", + "java.time.*", + "java.math.*", + "org.codelibs.*", + "[*" // Allow arrays + ); + + /** + * Default ObjectInputFilter that only allows safe classes to be deserialized. + * This filter rejects potentially dangerous classes while allowing common safe types. + */ + private static final ObjectInputFilter DEFAULT_FILTER = filterInfo -> { + final Class serialClass = filterInfo.serialClass(); + if (serialClass == null) { + return ObjectInputFilter.Status.UNDECIDED; + } + + final String className = serialClass.getName(); + + // Allow primitive types and their wrappers + if (serialClass.isPrimitive() || serialClass.isArray()) { + return ObjectInputFilter.Status.ALLOWED; + } + + // Check against allowed patterns + for (String allowedPattern : DEFAULT_ALLOWED_PATTERNS) { + if (allowedPattern.endsWith("*")) { + String prefix = allowedPattern.substring(0, allowedPattern.length() - 1); + if (className.startsWith(prefix)) { + return ObjectInputFilter.Status.ALLOWED; + } + } else if (className.equals(allowedPattern)) { + return ObjectInputFilter.Status.ALLOWED; + } + } + + // Reject everything else + return ObjectInputFilter.Status.REJECTED; + }; + /** * Tests if the object can be serialized. * @@ -79,17 +137,44 @@ public static byte[] fromObjectToBinary(final Object obj) { } /** - * Converts a byte array to an object. + * Converts a byte array to an object using the default security filter. + *

+ * This method applies a default ObjectInputFilter to prevent deserialization attacks. + * Only classes matching the default allowed patterns can be deserialized. + *

* * @param bytes the byte array (must not be {@literal null}) * @return the deserialized object + * @throws IORuntimeException if an I/O error occurs or if a class is rejected by the filter + * @throws ClassNotFoundRuntimeException if the class of a serialized object cannot be found */ public static Object fromBinaryToObject(final byte[] bytes) { + return fromBinaryToObject(bytes, DEFAULT_FILTER); + } + + /** + * Converts a byte array to an object using a custom security filter. + *

+ * This method allows you to specify a custom ObjectInputFilter for fine-grained + * control over which classes can be deserialized. Use this when the default filter + * is too restrictive or permissive for your use case. + *

+ * + * @param bytes the byte array (must not be {@literal null}) + * @param filter the ObjectInputFilter to use, or null to disable filtering + * @return the deserialized object + * @throws IORuntimeException if an I/O error occurs or if a class is rejected by the filter + * @throws ClassNotFoundRuntimeException if the class of a serialized object cannot be found + */ + public static Object fromBinaryToObject(final byte[] bytes, final ObjectInputFilter filter) { assertArgumentNotEmpty("bytes", bytes); try { final ByteArrayInputStream bais = new ByteArrayInputStream(bytes); final ObjectInputStream ois = new ObjectInputStream(bais); + if (filter != null) { + ois.setObjectInputFilter(filter); + } try { return ois.readObject(); } finally { @@ -102,4 +187,61 @@ public static Object fromBinaryToObject(final byte[] bytes) { } } + /** + * Creates a permissive filter that allows all classes to be deserialized. + *

+ * WARNING: Use this only when you completely trust the data source and have + * other security measures in place. Unrestricted deserialization can lead to + * remote code execution vulnerabilities. + *

+ * + * @return an ObjectInputFilter that allows all classes + */ + public static ObjectInputFilter createPermissiveFilter() { + return filterInfo -> ObjectInputFilter.Status.ALLOWED; + } + + /** + * Creates a custom filter that allows only the specified class patterns. + *

+ * Patterns can be exact class names or use wildcards with '*' at the end. + * For example: "com.example.*" allows all classes in the com.example package. + *

+ * + * @param allowedPatterns the patterns of classes to allow + * @return an ObjectInputFilter configured with the specified patterns + */ + public static ObjectInputFilter createCustomFilter(final Set allowedPatterns) { + assertArgumentNotNull("allowedPatterns", allowedPatterns); + + return filterInfo -> { + final Class serialClass = filterInfo.serialClass(); + if (serialClass == null) { + return ObjectInputFilter.Status.UNDECIDED; + } + + final String className = serialClass.getName(); + + // Allow primitive types and their wrappers + if (serialClass.isPrimitive() || serialClass.isArray()) { + return ObjectInputFilter.Status.ALLOWED; + } + + // Check against allowed patterns + for (String allowedPattern : allowedPatterns) { + if (allowedPattern.endsWith("*")) { + String prefix = allowedPattern.substring(0, allowedPattern.length() - 1); + if (className.startsWith(prefix)) { + return ObjectInputFilter.Status.ALLOWED; + } + } else if (className.equals(allowedPattern)) { + return ObjectInputFilter.Status.ALLOWED; + } + } + + // Reject everything else + return ObjectInputFilter.Status.REJECTED; + }; + } + } diff --git a/src/main/java/org/codelibs/core/lang/StringUtil.java b/src/main/java/org/codelibs/core/lang/StringUtil.java index 5017c6b..4b86398 100644 --- a/src/main/java/org/codelibs/core/lang/StringUtil.java +++ b/src/main/java/org/codelibs/core/lang/StringUtil.java @@ -50,21 +50,6 @@ protected StringUtil() { */ public static final String[] EMPTY_STRINGS = new String[0]; - static Object javaLangAccess = null; - - static Method newStringUnsafeMethod = null; - - static { - try { - final Class sharedSecretsClass = Class.forName("sun.misc.SharedSecrets"); - javaLangAccess = sharedSecretsClass.getDeclaredMethod("getJavaLangAccess").invoke(null); - final Class javaLangAccessClass = Class.forName("sun.misc.JavaLangAccess"); - newStringUnsafeMethod = javaLangAccessClass.getMethod("newStringUnsafe", char[].class); - } catch (final Throwable t) { - // ignore - // t.printStackTrace(); - } - } /** * Checks if the string is empty or null. @@ -720,27 +705,27 @@ private static boolean isAsciiPrintable(final char ch) { } /** - * Creates a new String from a char array without copying the array. + * Creates a new String from a char array. *

- * This method uses internal JDK APIs and may not be available in all Java versions. - * If the internal API is not available, it falls back to the standard String constructor. + * Note: This method no longer uses internal JDK APIs for safety and compatibility. + * It now uses the standard String constructor. *

* * @param chars * the char array - * @return a new String - */ + * @return a new String, or null if the input is null + * @deprecated This method originally used internal JDK APIs (sun.misc.SharedSecrets) for + * performance optimization, but those APIs have been removed for safety and + * compatibility with modern Java versions. This method now simply calls + * {@code new String(chars)}, providing no additional value. + * Use the {@link String#String(char[])} constructor directly instead. + * This method will be removed in a future version. + */ + @Deprecated public static String newStringUnsafe(final char[] chars) { if (chars == null) { return null; } - if (newStringUnsafeMethod != null) { - try { - return (String) newStringUnsafeMethod.invoke(javaLangAccess, chars); - } catch (final Throwable t) { - // ignore - } - } return new String(chars); } } diff --git a/src/main/java/org/codelibs/core/misc/Base64Util.java b/src/main/java/org/codelibs/core/misc/Base64Util.java index dcf6805..9e8e150 100644 --- a/src/main/java/org/codelibs/core/misc/Base64Util.java +++ b/src/main/java/org/codelibs/core/misc/Base64Util.java @@ -15,11 +15,18 @@ */ package org.codelibs.core.misc; +import java.util.Base64; + import org.codelibs.core.collection.ArrayUtil; import org.codelibs.core.lang.StringUtil; /** * Utility class for handling Base64 encoding and decoding. + *

+ * This class now uses the standard {@link java.util.Base64} implementation + * instead of a custom implementation, providing better security and performance. + * The API remains backward compatible with previous versions. + *

* * @author higa */ @@ -31,144 +38,37 @@ public abstract class Base64Util { protected Base64Util() { } - private static final char[] ENCODE_TABLE = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', - 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', - 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/' }; - - private static final char PAD = '='; - - private static final byte[] DECODE_TABLE = new byte[128]; - static { - for (int i = 0; i < DECODE_TABLE.length; i++) { - DECODE_TABLE[i] = Byte.MAX_VALUE; - } - for (int i = 0; i < ENCODE_TABLE.length; i++) { - DECODE_TABLE[ENCODE_TABLE[i]] = (byte) i; - } - } - /** * Encodes data in Base64. + *

+ * This method uses {@link java.util.Base64.Encoder} for encoding. + *

* * @param inData * The data to encode - * @return The encoded data + * @return The encoded data, or an empty string if the input is null or empty */ public static String encode(final byte[] inData) { if (ArrayUtil.isEmpty(inData)) { return ""; } - final int mod = inData.length % 3; - final int num = inData.length / 3; - char[] outData = null; - if (mod != 0) { - outData = new char[(num + 1) * 4]; - } else { - outData = new char[num * 4]; - } - for (int i = 0; i < num; i++) { - encode(inData, i * 3, outData, i * 4); - } - if (mod == 1) { - encode2pad(inData, num * 3, outData, num * 4); - } else if (mod == 2) { - encode1pad(inData, num * 3, outData, num * 4); - } - return new String(outData); + return Base64.getEncoder().encodeToString(inData); } /** * Decodes data encoded in Base64. + *

+ * This method uses {@link java.util.Base64.Decoder} for decoding. + *

* * @param inData * The data to decode - * @return The decoded data + * @return The decoded data, or null if the input is null or empty */ public static byte[] decode(final String inData) { if (StringUtil.isEmpty(inData)) { return null; } - final int num = inData.length() / 4 - 1; - final int lastBytes = getLastBytes(inData); - final byte[] outData = new byte[num * 3 + lastBytes]; - for (int i = 0; i < num; i++) { - decode(inData, i * 4, outData, i * 3); - } - switch (lastBytes) { - case 1: - decode1byte(inData, num * 4, outData, num * 3); - break; - case 2: - decode2byte(inData, num * 4, outData, num * 3); - break; - default: - decode(inData, num * 4, outData, num * 3); - } - return outData; - } - - private static void encode(final byte[] inData, final int inIndex, final char[] outData, final int outIndex) { - - final int i = ((inData[inIndex] & 0xff) << 16) + ((inData[inIndex + 1] & 0xff) << 8) + (inData[inIndex + 2] & 0xff); - outData[outIndex] = ENCODE_TABLE[i >> 18]; - outData[outIndex + 1] = ENCODE_TABLE[i >> 12 & 0x3f]; - outData[outIndex + 2] = ENCODE_TABLE[i >> 6 & 0x3f]; - outData[outIndex + 3] = ENCODE_TABLE[i & 0x3f]; - } - - private static void encode2pad(final byte[] inData, final int inIndex, final char[] outData, final int outIndex) { - - final int i = inData[inIndex] & 0xff; - outData[outIndex] = ENCODE_TABLE[i >> 2]; - outData[outIndex + 1] = ENCODE_TABLE[i << 4 & 0x3f]; - outData[outIndex + 2] = PAD; - outData[outIndex + 3] = PAD; - } - - private static void encode1pad(final byte[] inData, final int inIndex, final char[] outData, final int outIndex) { - - final int i = ((inData[inIndex] & 0xff) << 8) + (inData[inIndex + 1] & 0xff); - outData[outIndex] = ENCODE_TABLE[i >> 10]; - outData[outIndex + 1] = ENCODE_TABLE[i >> 4 & 0x3f]; - outData[outIndex + 2] = ENCODE_TABLE[i << 2 & 0x3f]; - outData[outIndex + 3] = PAD; - } - - private static void decode(final String inData, final int inIndex, final byte[] outData, final int outIndex) { - - final byte b0 = DECODE_TABLE[inData.charAt(inIndex)]; - final byte b1 = DECODE_TABLE[inData.charAt(inIndex + 1)]; - final byte b2 = DECODE_TABLE[inData.charAt(inIndex + 2)]; - final byte b3 = DECODE_TABLE[inData.charAt(inIndex + 3)]; - outData[outIndex] = (byte) (b0 << 2 & 0xfc | b1 >> 4 & 0x3); - outData[outIndex + 1] = (byte) (b1 << 4 & 0xf0 | b2 >> 2 & 0xf); - outData[outIndex + 2] = (byte) (b2 << 6 & 0xc0 | b3 & 0x3f); - } - - private static void decode1byte(final String inData, final int inIndex, final byte[] outData, final int outIndex) { - - final byte b0 = DECODE_TABLE[inData.charAt(inIndex)]; - final byte b1 = DECODE_TABLE[inData.charAt(inIndex + 1)]; - outData[outIndex] = (byte) (b0 << 2 & 0xfc | b1 >> 4 & 0x3); - } - - private static void decode2byte(final String inData, final int inIndex, final byte[] outData, final int outIndex) { - - final byte b0 = DECODE_TABLE[inData.charAt(inIndex)]; - final byte b1 = DECODE_TABLE[inData.charAt(inIndex + 1)]; - final byte b2 = DECODE_TABLE[inData.charAt(inIndex + 2)]; - outData[outIndex] = (byte) (b0 << 2 & 0xfc | b1 >> 4 & 0x3); - outData[outIndex + 1] = (byte) (b1 << 4 & 0xf0 | b2 >> 2 & 0xf); - } - - private static int getLastBytes(final String inData) { - final int len = inData.length(); - if (inData.charAt(len - 2) == PAD) { - return 1; - } else if (inData.charAt(len - 1) == PAD) { - return 2; - } else { - return 3; - } + return Base64.getDecoder().decode(inData); } } diff --git a/src/test/java/org/codelibs/core/crypto/CachedCipherTest.java b/src/test/java/org/codelibs/core/crypto/CachedCipherTest.java new file mode 100644 index 0000000..e17c189 --- /dev/null +++ b/src/test/java/org/codelibs/core/crypto/CachedCipherTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2012-2025 CodeLibs Project and the Others. + * + * 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 + * + * http://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 org.codelibs.core.crypto; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThat; + +import org.junit.Test; + +/** + * Tests for {@link CachedCipher}. + */ +public class CachedCipherTest { + + @Test + public void testEncryptDecryptBytes() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final byte[] original = "Hello World".getBytes(); + final byte[] encrypted = cipher.encrypt(original); + final byte[] decrypted = cipher.decrypt(encrypted); + + assertThat(encrypted, is(not(original))); + assertArrayEquals(original, decrypted); + } + + @Test + public void testEncryptDecryptText() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final String original = "Hello World"; + final String encrypted = cipher.encryptText(original); + final String decrypted = cipher.decryptText(encrypted); + + assertThat(encrypted, is(not(original))); + assertThat(decrypted, is(original)); + } + + @Test + @SuppressWarnings("deprecation") + public void testDeprecatedEncryptoDecryptoBytes() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final byte[] original = "Hello World".getBytes(); + final byte[] encrypted = cipher.encrypto(original); + final byte[] decrypted = cipher.decrypto(encrypted); + + assertThat(encrypted, is(not(original))); + assertArrayEquals(original, decrypted); + } + + @Test + @SuppressWarnings("deprecation") + public void testDeprecatedEncryptoDecryptoText() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final String original = "Hello World"; + final String encrypted = cipher.encryptoText(original); + final String decrypted = cipher.decryptoText(encrypted); + + assertThat(encrypted, is(not(original))); + assertThat(decrypted, is(original)); + } + + @Test + public void testBackwardCompatibilityEncryptDecrypt() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final String original = "Hello World"; + + // Encrypt with new method, decrypt with old method + @SuppressWarnings("deprecation") + final String encrypted1 = cipher.encryptText(original); + @SuppressWarnings("deprecation") + final String decrypted1 = cipher.decryptoText(encrypted1); + assertThat(decrypted1, is(original)); + + // Encrypt with old method, decrypt with new method + @SuppressWarnings("deprecation") + final String encrypted2 = cipher.encryptoText(original); + final String decrypted2 = cipher.decryptText(encrypted2); + assertThat(decrypted2, is(original)); + } + + @Test + public void testDifferentKeys() { + final CachedCipher cipher1 = new CachedCipher(); + cipher1.setKey("key1"); + + final CachedCipher cipher2 = new CachedCipher(); + cipher2.setKey("key2"); + + final String original = "Hello World"; + final String encrypted1 = cipher1.encryptText(original); + final String encrypted2 = cipher2.encryptText(original); + + assertThat(encrypted1, is(not(encrypted2))); + assertThat(cipher1.decryptText(encrypted1), is(original)); + assertThat(cipher2.decryptText(encrypted2), is(original)); + } + + @Test + public void testEmptyString() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final String original = ""; + final String encrypted = cipher.encryptText(original); + final String decrypted = cipher.decryptText(encrypted); + + assertThat(decrypted, is(original)); + } + + @Test + public void testLongText() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append("Long text for testing. "); + } + final String original = sb.toString(); + final String encrypted = cipher.encryptText(original); + final String decrypted = cipher.decryptText(encrypted); + + assertThat(decrypted, is(original)); + } + + @Test + public void testUnicodeText() { + final CachedCipher cipher = new CachedCipher(); + cipher.setKey("mySecretKey"); + + final String original = "Hello 世界 مرحبا мир"; + final String encrypted = cipher.encryptText(original); + final String decrypted = cipher.decryptText(encrypted); + + assertThat(decrypted, is(original)); + } +} diff --git a/src/test/java/org/codelibs/core/io/FileUtilTest.java b/src/test/java/org/codelibs/core/io/FileUtilTest.java index 9e7655a..6678091 100644 --- a/src/test/java/org/codelibs/core/io/FileUtilTest.java +++ b/src/test/java/org/codelibs/core/io/FileUtilTest.java @@ -17,13 +17,24 @@ import static org.codelibs.core.io.FileUtil.readBytes; import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.codelibs.core.exception.IORuntimeException; import org.codelibs.core.net.URLUtil; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; /** * @author koichik @@ -35,6 +46,9 @@ public class FileUtilTest { File inputFile = URLUtil.toFile(url); + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + /** * @throws Exception */ @@ -52,6 +66,279 @@ public void testReadUTF8() throws Exception { assertThat(FileUtil.readUTF8(getPath("hoge_utf8.txt")), is("あ")); } + /** + * Test isPathSafe with safe path + * + * @throws Exception + */ + @Test + public void testIsPathSafe_SafePath() throws Exception { + final Path baseDir = tempFolder.getRoot().toPath(); + final Path safePath = baseDir.resolve("subdir/file.txt"); + + assertTrue("Safe path should be allowed", FileUtil.isPathSafe(safePath, baseDir)); + } + + /** + * Test isPathSafe with path traversal attempt + * + * @throws Exception + */ + @Test + public void testIsPathSafe_PathTraversalAttempt() throws Exception { + final Path baseDir = tempFolder.getRoot().toPath(); + final Path traversalPath = baseDir.resolve("../../../etc/passwd"); + + assertFalse("Path traversal should be blocked", FileUtil.isPathSafe(traversalPath, baseDir)); + } + + /** + * Test isPathSafe with File objects + * + * @throws Exception + */ + @Test + public void testIsPathSafe_WithFiles() throws Exception { + final File baseDir = tempFolder.getRoot(); + final File safeFile = new File(baseDir, "subdir/file.txt"); + final File unsafeFile = new File(baseDir, "../../../etc/passwd"); + + assertTrue("Safe file should be allowed", FileUtil.isPathSafe(safeFile, baseDir)); + assertFalse("Unsafe file should be blocked", FileUtil.isPathSafe(unsafeFile, baseDir)); + } + + /** + * Test isPathSafe with path inside base directory + * + * @throws Exception + */ + @Test + public void testIsPathSafe_PathInsideBase() throws Exception { + final Path baseDir = tempFolder.getRoot().toPath(); + final Path subDir = Files.createDirectory(baseDir.resolve("subdir")); + final Path file = Files.createFile(subDir.resolve("test.txt")); + + assertTrue("Path inside base should be allowed", FileUtil.isPathSafe(file, baseDir)); + } + + /** + * Test isPathSafe with path outside base directory + * + * @throws Exception + */ + @Test + public void testIsPathSafe_PathOutsideBase() throws Exception { + final Path baseDir = tempFolder.getRoot().toPath(); + final Path outsidePath = Paths.get("/tmp/outside.txt"); + + assertFalse("Path outside base should be blocked", FileUtil.isPathSafe(outsidePath, baseDir)); + } + + /** + * Test isPathSafe with same path as base + * + * @throws Exception + */ + @Test + public void testIsPathSafe_SameAsBase() throws Exception { + final Path baseDir = tempFolder.getRoot().toPath(); + + assertTrue("Base directory itself should be allowed", FileUtil.isPathSafe(baseDir, baseDir)); + } + + /** + * Test readBytes with large file throws exception + * + * @throws Exception + */ + @Test + public void testReadBytes_LargeFile() throws Exception { + final File largeFile = tempFolder.newFile("large.dat"); + + // Create a file larger than MAX_BUF_SIZE (10MB) + // Write 11MB of data + try (FileOutputStream fos = new FileOutputStream(largeFile)) { + final byte[] chunk = new byte[1024 * 1024]; // 1MB + for (int i = 0; i < 11; i++) { // Write 11MB + fos.write(chunk); + } + } + + try { + FileUtil.readBytes(largeFile); + fail("Expected IORuntimeException for large file"); + } catch (final IORuntimeException e) { + assertTrue("Error message should mention file size", e.getMessage().contains("File too large")); + } + } + + /** + * Test readBytes with file within size limit + * + * @throws Exception + */ + @Test + public void testReadBytes_NormalFile() throws Exception { + final File normalFile = tempFolder.newFile("normal.txt"); + final String content = "Test content for normal file"; + + try (FileOutputStream fos = new FileOutputStream(normalFile)) { + fos.write(content.getBytes("UTF-8")); + } + + final byte[] result = FileUtil.readBytes(normalFile); + assertThat(new String(result, "UTF-8"), is(content)); + } + + /** + * Test readBytes with empty file + * + * @throws Exception + */ + @Test + public void testReadBytes_EmptyFile() throws Exception { + final File emptyFile = tempFolder.newFile("empty.txt"); + + final byte[] result = FileUtil.readBytes(emptyFile); + assertThat(result.length, is(0)); + } + + /** + * Test readBytes with null file throws exception + */ + @Test + public void testReadBytes_NullFile() { + try { + FileUtil.readBytes(null); + fail("Expected IllegalArgumentException"); + } catch (final IllegalArgumentException e) { + // Expected + } + } + + /** + * Test isPathSafe with null path throws exception + */ + @Test + public void testIsPathSafe_NullPath() { + final Path baseDir = tempFolder.getRoot().toPath(); + try { + FileUtil.isPathSafe((Path) null, baseDir); + fail("Expected IllegalArgumentException"); + } catch (final IllegalArgumentException e) { + // Expected + } + } + + /** + * Test isPathSafe with null base directory throws exception + */ + @Test + public void testIsPathSafe_NullBase() { + final Path path = Paths.get("/tmp/test.txt"); + try { + FileUtil.isPathSafe(path, null); + fail("Expected IllegalArgumentException"); + } catch (final IllegalArgumentException e) { + // Expected + } + } + + /** + * Test readBytes with custom maxSize - file within limit + * + * @throws Exception + */ + @Test + public void testReadBytes_CustomMaxSize_WithinLimit() throws Exception { + final File file = tempFolder.newFile("small.txt"); + final String content = "Small content"; + final FileOutputStream out = new FileOutputStream(file); + try { + out.write(content.getBytes("UTF-8")); + } finally { + out.close(); + } + + // Set maxSize larger than file size + final byte[] bytes = FileUtil.readBytes(file, 1024); + assertThat(new String(bytes, "UTF-8"), is(content)); + } + + /** + * Test readBytes with custom maxSize - file exceeds limit + * + * @throws Exception + */ + @Test(expected = IORuntimeException.class) + public void testReadBytes_CustomMaxSize_ExceedsLimit() throws Exception { + final File file = tempFolder.newFile("medium.txt"); + final byte[] data = new byte[1024]; // 1KB + final FileOutputStream out = new FileOutputStream(file); + try { + out.write(data); + } finally { + out.close(); + } + + // Set maxSize smaller than file size (should throw exception) + FileUtil.readBytes(file, 512); + } + + /** + * Test readBytes with custom maxSize of zero + * + * @throws Exception + */ + @Test(expected = IORuntimeException.class) + public void testReadBytes_CustomMaxSize_Zero() throws Exception { + final File file = tempFolder.newFile("any.txt"); + final FileOutputStream out = new FileOutputStream(file); + try { + out.write("content".getBytes("UTF-8")); + } finally { + out.close(); + } + + // Set maxSize to zero (should throw exception for any non-empty file) + FileUtil.readBytes(file, 0); + } + + /** + * Test readBytes with custom maxSize for empty file + * + * @throws Exception + */ + @Test + public void testReadBytes_CustomMaxSize_EmptyFile() throws Exception { + final File file = tempFolder.newFile("empty.txt"); + + // Empty file should work with any maxSize including 0 + final byte[] bytes = FileUtil.readBytes(file, 0); + assertThat(bytes.length, is(0)); + } + + /** + * Test readBytes with very large custom maxSize + * + * @throws Exception + */ + @Test + public void testReadBytes_CustomMaxSize_VeryLarge() throws Exception { + final File file = tempFolder.newFile("test.txt"); + final String content = "Test content"; + final FileOutputStream out = new FileOutputStream(file); + try { + out.write(content.getBytes("UTF-8")); + } finally { + out.close(); + } + + // Set maxSize very large + final byte[] bytes = FileUtil.readBytes(file, Long.MAX_VALUE); + assertThat(new String(bytes, "UTF-8"), is(content)); + } + private String getPath(final String fileName) { return getClass().getName().replace('.', '/').replaceFirst(getClass().getSimpleName(), fileName); } diff --git a/src/test/java/org/codelibs/core/io/SerializeUtilTest.java b/src/test/java/org/codelibs/core/io/SerializeUtilTest.java index c9cb251..d9fc5d4 100644 --- a/src/test/java/org/codelibs/core/io/SerializeUtilTest.java +++ b/src/test/java/org/codelibs/core/io/SerializeUtilTest.java @@ -15,6 +15,17 @@ */ package org.codelibs.core.io; +import java.io.ObjectInputFilter; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.codelibs.core.exception.IORuntimeException; + import junit.framework.TestCase; /** @@ -42,4 +53,171 @@ public void testObjectAndBinary() throws Exception { final byte[] binary = SerializeUtil.fromObjectToBinary(o); assertEquals(o, SerializeUtil.fromBinaryToObject(binary)); } + + /** + * Test default filter allows common safe classes + * + * @throws Exception + */ + public void testFromBinaryToObject_DefaultFilter_AllowsSafeClasses() throws Exception { + // Test String + final String str = "test string"; + byte[] binary = SerializeUtil.fromObjectToBinary(str); + assertEquals(str, SerializeUtil.fromBinaryToObject(binary)); + + // Test Integer + final Integer num = 42; + binary = SerializeUtil.fromObjectToBinary(num); + assertEquals(num, SerializeUtil.fromBinaryToObject(binary)); + + // Test String array + final String[] arr = new String[] { "a", "b", "c" }; + binary = SerializeUtil.fromObjectToBinary(arr); + final String[] result = (String[]) SerializeUtil.fromBinaryToObject(binary); + assertEquals(arr.length, result.length); + assertEquals(arr[0], result[0]); + + // Test ArrayList + final List list = new ArrayList<>(); + list.add("item1"); + list.add("item2"); + binary = SerializeUtil.fromObjectToBinary(list); + @SuppressWarnings("unchecked") + final List resultList = (List) SerializeUtil.fromBinaryToObject(binary); + assertEquals(list.size(), resultList.size()); + assertEquals(list.get(0), resultList.get(0)); + + // Test HashMap + final Map map = new HashMap<>(); + map.put("one", 1); + map.put("two", 2); + binary = SerializeUtil.fromObjectToBinary(map); + @SuppressWarnings("unchecked") + final Map resultMap = (Map) SerializeUtil.fromBinaryToObject(binary); + assertEquals(map.size(), resultMap.size()); + assertEquals(map.get("one"), resultMap.get("one")); + } + + /** + * Test custom filter functionality + * + * @throws Exception + */ + public void testFromBinaryToObject_CustomFilter() throws Exception { + final Set allowedPatterns = new HashSet<>(); + allowedPatterns.add("java.lang.*"); + allowedPatterns.add("java.util.*"); + + final ObjectInputFilter customFilter = SerializeUtil.createCustomFilter(allowedPatterns); + + // Test allowed class + final String str = "test"; + final byte[] binary = SerializeUtil.fromObjectToBinary(str); + final Object result = SerializeUtil.fromBinaryToObject(binary, customFilter); + assertEquals(str, result); + } + + /** + * Test permissive filter allows all classes + * + * @throws Exception + */ + public void testFromBinaryToObject_PermissiveFilter() throws Exception { + final ObjectInputFilter permissiveFilter = SerializeUtil.createPermissiveFilter(); + + // Create a custom class instance + final TestSerializableClass obj = new TestSerializableClass("test", 123); + final byte[] binary = SerializeUtil.fromObjectToBinary(obj); + + // Should work with permissive filter + final TestSerializableClass result = (TestSerializableClass) SerializeUtil.fromBinaryToObject(binary, permissiveFilter); + assertEquals(obj.name, result.name); + assertEquals(obj.value, result.value); + } + + /** + * Test null filter disables filtering + * + * @throws Exception + */ + public void testFromBinaryToObject_NullFilter() throws Exception { + final String str = "test"; + final byte[] binary = SerializeUtil.fromObjectToBinary(str); + final Object result = SerializeUtil.fromBinaryToObject(binary, null); + assertEquals(str, result); + } + + /** + * Test that default filter allows org.codelibs classes + * + * @throws Exception + */ + public void testFromBinaryToObject_DefaultFilter_AllowsCodelibsClasses() throws Exception { + // The default filter should allow org.codelibs.* classes + final TestSerializableClass obj = new TestSerializableClass("test", 456); + final byte[] binary = SerializeUtil.fromObjectToBinary(obj); + + // Should work because TestSerializableClass is in org.codelibs package + final TestSerializableClass result = (TestSerializableClass) SerializeUtil.fromBinaryToObject(binary); + assertEquals(obj.name, result.name); + assertEquals(obj.value, result.value); + } + + /** + * Test createCustomFilter with null throws exception + */ + public void testCreateCustomFilter_NullPatterns() { + try { + SerializeUtil.createCustomFilter(null); + fail("Expected IllegalArgumentException"); + } catch (final IllegalArgumentException e) { + // Expected + } + } + + /** + * Test serialization with empty array + * + * @throws Exception + */ + public void testFromBinaryToObject_EmptyArray() throws Exception { + final String[] emptyArray = new String[0]; + final byte[] binary = SerializeUtil.fromObjectToBinary(emptyArray); + final String[] result = (String[]) SerializeUtil.fromBinaryToObject(binary); + assertEquals(0, result.length); + } + + /** + * Test serialization with null values in collection + * + * @throws Exception + */ + public void testFromBinaryToObject_CollectionWithNulls() throws Exception { + final List list = new ArrayList<>(); + list.add("first"); + list.add(null); + list.add("third"); + final byte[] binary = SerializeUtil.fromObjectToBinary(list); + @SuppressWarnings("unchecked") + final List result = (List) SerializeUtil.fromBinaryToObject(binary); + assertEquals(3, result.size()); + assertEquals("first", result.get(0)); + assertNull(result.get(1)); + assertEquals("third", result.get(2)); + } + + /** + * Test helper class for serialization tests + */ + public static class TestSerializableClass implements Serializable { + private static final long serialVersionUID = 1L; + + public String name; + public int value; + + public TestSerializableClass(final String name, final int value) { + this.name = name; + this.value = value; + } + } } diff --git a/src/test/java/org/codelibs/core/lang/StringUtilTest.java b/src/test/java/org/codelibs/core/lang/StringUtilTest.java index 54053d6..441618c 100644 --- a/src/test/java/org/codelibs/core/lang/StringUtilTest.java +++ b/src/test/java/org/codelibs/core/lang/StringUtilTest.java @@ -351,17 +351,6 @@ public void testDefaultStringDefaultStr() { @Test public void testNewStringUnsafe() { assertNull(StringUtil.newStringUnsafe(null)); - Method newStringUnsafeMethod = StringUtil.newStringUnsafeMethod; - if (newStringUnsafeMethod != null) { - StringUtil.newStringUnsafeMethod = null; - char[] chars = new char[0]; - assertThat(StringUtil.newStringUnsafe(chars), is("")); - chars = new char[] { 'a', 'b', 'c' }; - assertThat(StringUtil.newStringUnsafe(chars), is("abc")); - StringUtil.newStringUnsafeMethod = newStringUnsafeMethod; - chars = new char[] { 'a', 'b', 'c', ' ' }; - assertThat(StringUtil.newStringUnsafe(chars), is("abc ")); - } char[] chars = new char[0]; assertThat(StringUtil.newStringUnsafe(chars), is("")); chars = new char[] { 'a', 'b', 'c' }; diff --git a/src/test/java/org/codelibs/core/misc/Base64UtilTest.java b/src/test/java/org/codelibs/core/misc/Base64UtilTest.java index 066ccf5..42cea89 100644 --- a/src/test/java/org/codelibs/core/misc/Base64UtilTest.java +++ b/src/test/java/org/codelibs/core/misc/Base64UtilTest.java @@ -47,4 +47,129 @@ public void testDecode() throws Exception { assertEquals("2", BINARY_DATA[i], decodedData[i]); } } + + /** + * Test encode with empty byte array + * + * @throws Exception + */ + public void testEncode_EmptyArray() throws Exception { + final String result = Base64Util.encode(new byte[0]); + assertEquals("Empty array should return empty string", "", result); + } + + /** + * Test encode with null returns empty string + * + * @throws Exception + */ + public void testEncode_Null() throws Exception { + final String result = Base64Util.encode(null); + assertEquals("Null should return empty string", "", result); + } + + /** + * Test decode with empty string + * + * @throws Exception + */ + public void testDecode_EmptyString() throws Exception { + final byte[] result = Base64Util.decode(""); + assertNull("Empty string should return null", result); + } + + /** + * Test decode with null + * + * @throws Exception + */ + public void testDecode_Null() throws Exception { + final byte[] result = Base64Util.decode(null); + assertNull("Null should return null", result); + } + + /** + * Test encode/decode round trip with various data + * + * @throws Exception + */ + public void testEncodeDecode_RoundTrip() throws Exception { + // Test with ASCII text + final String text = "Hello, World!"; + final byte[] textBytes = text.getBytes("UTF-8"); + final String encoded = Base64Util.encode(textBytes); + final byte[] decoded = Base64Util.decode(encoded); + assertEquals("Round trip should preserve data", text, new String(decoded, "UTF-8")); + + // Test with UTF-8 text + final String utf8Text = "こんにちは世界"; + final byte[] utf8Bytes = utf8Text.getBytes("UTF-8"); + final String utf8Encoded = Base64Util.encode(utf8Bytes); + final byte[] utf8Decoded = Base64Util.decode(utf8Encoded); + assertEquals("Round trip should preserve UTF-8 data", utf8Text, new String(utf8Decoded, "UTF-8")); + + // Test with binary data + final byte[] binaryData = new byte[] { 0, 1, 2, 3, 127, (byte) 128, (byte) 255 }; + final String binaryEncoded = Base64Util.encode(binaryData); + final byte[] binaryDecoded = Base64Util.decode(binaryEncoded); + assertEquals("Binary data length should match", binaryData.length, binaryDecoded.length); + for (int i = 0; i < binaryData.length; i++) { + assertEquals("Binary data should match at position " + i, binaryData[i], binaryDecoded[i]); + } + } + + /** + * Test encode with single byte + * + * @throws Exception + */ + public void testEncode_SingleByte() throws Exception { + final byte[] singleByte = new byte[] { 'A' }; + final String encoded = Base64Util.encode(singleByte); + assertNotNull("Encoded result should not be null", encoded); + final byte[] decoded = Base64Util.decode(encoded); + assertEquals("Decoded should have same length", 1, decoded.length); + assertEquals("Decoded byte should match", singleByte[0], decoded[0]); + } + + /** + * Test encode with two bytes + * + * @throws Exception + */ + public void testEncode_TwoBytes() throws Exception { + final byte[] twoBytes = new byte[] { 'A', 'B' }; + final String encoded = Base64Util.encode(twoBytes); + assertNotNull("Encoded result should not be null", encoded); + final byte[] decoded = Base64Util.decode(encoded); + assertEquals("Decoded should have same length", 2, decoded.length); + assertEquals("First byte should match", twoBytes[0], decoded[0]); + assertEquals("Second byte should match", twoBytes[1], decoded[1]); + } + + /** + * Test backward compatibility with standard Base64 + * + * @throws Exception + */ + public void testBackwardCompatibility() throws Exception { + // These test cases ensure that the new java.util.Base64 implementation + // produces the same output as the old custom implementation + + // Test case 1: Standard padding + final byte[] data1 = "abc".getBytes(); + assertEquals("YWJj", Base64Util.encode(data1)); + + // Test case 2: Single padding + final byte[] data2 = "abcd".getBytes(); + assertEquals("YWJjZA==", Base64Util.encode(data2)); + + // Test case 3: Double padding + final byte[] data3 = "abcde".getBytes(); + assertEquals("YWJjZGU=", Base64Util.encode(data3)); + + // Test case 4: No padding + final byte[] data4 = "abcdef".getBytes(); + assertEquals("YWJjZGVm", Base64Util.encode(data4)); + } }