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}