001package com.streamconverter.command.impl; 002 003import com.streamconverter.command.AbstractStreamCommand; 004import java.io.IOException; 005import java.io.InputStream; 006import java.io.OutputStream; 007import java.nio.file.Files; 008import java.nio.file.Path; 009import java.security.GeneralSecurityException; 010import java.security.SecureRandom; 011import javax.crypto.Cipher; 012import javax.crypto.CipherInputStream; 013import javax.crypto.CipherOutputStream; 014import javax.crypto.KeyGenerator; 015import javax.crypto.SecretKey; 016import javax.crypto.spec.GCMParameterSpec; 017 018/** 019 * A pipeline command that buffers data through a temporary file between pipeline stages. 020 * 021 * <p>This command solves the problem where validation failures in a preceding stage can cause 022 * invalid data to be written to the output stream. By buffering through a temporary file, the 023 * upstream stage completes fully before the downstream stage begins, ensuring data integrity. 024 * 025 * <p>Usage example: 026 * 027 * <pre>{@code 028 * StreamConverter.create( 029 * validateCmd, 030 * FileBufferCommand.create(), 031 * transformCmd 032 * ); 033 * }</pre> 034 * 035 * <p>The encrypted variant ({@link #createEncrypted()}) uses AES-256-GCM to protect sensitive data 036 * written to the temporary file. 037 */ 038public class FileBufferCommand extends AbstractStreamCommand { 039 040 private static final int BUFFER_SIZE = 64 * 1024; 041 private static final int AES_KEY_BITS = 256; 042 private static final int GCM_IV_LENGTH = 12; 043 private static final int GCM_TAG_LENGTH = 128; 044 private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding"; 045 private static final SecureRandom SECURE_RANDOM = new SecureRandom(); 046 047 private final boolean encrypted; 048 049 private FileBufferCommand(boolean encrypted) { 050 this.encrypted = encrypted; 051 } 052 053 /** 054 * Creates a {@code FileBufferCommand} that buffers data through a plaintext temporary file. 055 * 056 * @return a new {@code FileBufferCommand} instance 057 */ 058 public static FileBufferCommand create() { 059 return new FileBufferCommand(false); 060 } 061 062 /** 063 * Creates a {@code FileBufferCommand} that buffers data through an AES-256-GCM encrypted 064 * temporary file. 065 * 066 * <p>The encryption key and IV are generated per execution and never persisted, providing 067 * confidentiality for sensitive pipeline data. 068 * 069 * @return a new {@code FileBufferCommand} instance with encryption enabled 070 */ 071 public static FileBufferCommand createEncrypted() { 072 return new FileBufferCommand(true); 073 } 074 075 /** 076 * Executes the buffering operation: writes the input stream to a temporary file, then reads the 077 * temporary file to the output stream. 078 * 079 * @param inputStream the input stream to read data from 080 * @param outputStream the output stream to write data to 081 * @throws IOException if an I/O error occurs, or if decryption authentication fails 082 */ 083 @Override 084 public void execute(InputStream inputStream, OutputStream outputStream) throws IOException { 085 Path tempFile = Files.createTempFile("streamconverter-", ".tmp"); 086 Thread shutdownHook = new Thread(() -> deleteSilently(tempFile), "FileBufferCommand-cleanup"); 087 088 try { 089 registerShutdownHook(shutdownHook, tempFile); 090 if (encrypted) { 091 SecretKey key = generateAesKey(); 092 byte[] iv = generateIv(); 093 writeEncrypted(inputStream, tempFile, key, iv); 094 readDecrypted(tempFile, outputStream, key); 095 } else { 096 write(inputStream, tempFile); 097 read(tempFile, outputStream); 098 } 099 } finally { 100 try { 101 Files.deleteIfExists(tempFile); 102 } finally { 103 deregisterShutdownHook(shutdownHook); 104 } 105 } 106 } 107 108 private void write(InputStream inputStream, Path tempFile) throws IOException { 109 try (OutputStream fileOut = Files.newOutputStream(tempFile)) { 110 byte[] buffer = new byte[BUFFER_SIZE]; 111 int bytesRead; 112 while ((bytesRead = inputStream.read(buffer)) != -1) { 113 fileOut.write(buffer, 0, bytesRead); 114 } 115 } 116 } 117 118 private void read(Path tempFile, OutputStream outputStream) throws IOException { 119 try (InputStream fileIn = Files.newInputStream(tempFile)) { 120 byte[] buffer = new byte[BUFFER_SIZE]; 121 int bytesRead; 122 while ((bytesRead = fileIn.read(buffer)) != -1) { 123 outputStream.write(buffer, 0, bytesRead); 124 } 125 } 126 } 127 128 private void writeEncrypted(InputStream inputStream, Path tempFile, SecretKey key, byte[] iv) 129 throws IOException { 130 try (OutputStream fileOut = Files.newOutputStream(tempFile)) { 131 fileOut.write(iv); 132 Cipher cipher = initCipher(Cipher.ENCRYPT_MODE, key, iv); 133 try (CipherOutputStream cipherOut = new CipherOutputStream(fileOut, cipher)) { 134 byte[] buffer = new byte[BUFFER_SIZE]; 135 int bytesRead; 136 while ((bytesRead = inputStream.read(buffer)) != -1) { 137 cipherOut.write(buffer, 0, bytesRead); 138 } 139 } 140 } 141 } 142 143 private void readDecrypted(Path tempFile, OutputStream outputStream, SecretKey key) 144 throws IOException { 145 try (InputStream fileIn = Files.newInputStream(tempFile)) { 146 byte[] storedIv = new byte[GCM_IV_LENGTH]; 147 int totalRead = 0; 148 while (totalRead < GCM_IV_LENGTH) { 149 int bytesRead = fileIn.read(storedIv, totalRead, GCM_IV_LENGTH - totalRead); 150 if (bytesRead == -1) { 151 throw new IOException("Unexpected end of encrypted file while reading IV"); 152 } 153 totalRead += bytesRead; 154 } 155 Cipher cipher = initCipher(Cipher.DECRYPT_MODE, key, storedIv); 156 try (CipherInputStream cipherIn = new CipherInputStream(fileIn, cipher)) { 157 byte[] buffer = new byte[BUFFER_SIZE]; 158 int bytesRead; 159 while ((bytesRead = cipherIn.read(buffer)) != -1) { 160 outputStream.write(buffer, 0, bytesRead); 161 } 162 } 163 } 164 } 165 166 private SecretKey generateAesKey() throws IOException { 167 try { 168 KeyGenerator keyGen = KeyGenerator.getInstance("AES"); 169 keyGen.init(AES_KEY_BITS, SECURE_RANDOM); 170 return keyGen.generateKey(); 171 } catch (GeneralSecurityException e) { 172 throw new IOException("Failed to generate AES key", e); 173 } 174 } 175 176 private byte[] generateIv() { 177 byte[] iv = new byte[GCM_IV_LENGTH]; 178 SECURE_RANDOM.nextBytes(iv); 179 return iv; 180 } 181 182 private Cipher initCipher(int mode, SecretKey key, byte[] iv) throws IOException { 183 try { 184 Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); 185 cipher.init(mode, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); 186 return cipher; 187 } catch (GeneralSecurityException e) { 188 throw new IOException("Encryption/decryption failed", e); 189 } 190 } 191 192 private void registerShutdownHook(Thread shutdownHook, Path tempFile) { 193 try { 194 Runtime.getRuntime().addShutdownHook(shutdownHook); 195 } catch (IllegalStateException | SecurityException e) { 196 // JVM shutting down or security manager disallows hooks: fall back to deleteOnExit 197 log.debug( 198 "Could not register shutdown hook; falling back to deleteOnExit for {}", tempFile, e); 199 tempFile.toFile().deleteOnExit(); 200 } 201 } 202 203 private void deregisterShutdownHook(Thread shutdownHook) { 204 try { 205 Runtime.getRuntime().removeShutdownHook(shutdownHook); 206 } catch (IllegalStateException e) { 207 log.debug("JVM is shutting down; could not deregister shutdown hook", e); 208 } catch (SecurityException e) { 209 log.debug("Security manager prevented shutdown hook deregistration", e); 210 } 211 } 212 213 private void deleteSilently(Path path) { 214 try { 215 Files.deleteIfExists(path); 216 } catch (IOException e) { 217 log.warn("Failed to delete temporary file: {}", path, e); 218 } 219 } 220}