]> source.dussan.org Git - poi.git/commitdiff
#64387 - Big POIFS stream result in OOM
authorAndreas Beeker <kiwiwings@apache.org>
Tue, 28 Apr 2020 23:08:05 +0000 (23:08 +0000)
committerAndreas Beeker <kiwiwings@apache.org>
Tue, 28 Apr 2020 23:08:05 +0000 (23:08 +0000)
git-svn-id: https://svn.apache.org/repos/asf/poi/trunk@1877144 13f79535-47bb-0310-9956-ffa450edef68

src/java/org/apache/poi/poifs/filesystem/BlockStore.java
src/java/org/apache/poi/poifs/filesystem/POIFSFileSystem.java
src/java/org/apache/poi/poifs/filesystem/POIFSMiniStore.java
src/java/org/apache/poi/poifs/filesystem/POIFSStream.java
src/java/org/apache/poi/poifs/nio/FileBackedDataSource.java
src/ooxml/testcases/org/apache/poi/poifs/crypt/TestEncryptor.java

index a56d111f3fe82a10cea83321a05f7f8e5e0f9789..bdd016f86055e50ad091552581588cd849d5c039 100644 (file)
@@ -1,4 +1,3 @@
-
 /* ====================================================================
    Licensed to the Apache Software Foundation (ASF) under one or more
    contributor license agreements.  See the NOTICE file distributed with
@@ -32,53 +31,59 @@ public abstract class BlockStore {
     * Returns the size of the blocks managed through the block store.
     */
    protected abstract int getBlockStoreBlockSize();
-   
+
     /**
      * Load the block at the given offset.
      */
     protected abstract ByteBuffer getBlockAt(final int offset) throws IOException;
-    
+
     /**
      * Extends the file if required to hold blocks up to
-     *  the specified offset, and return the block from there. 
+     *  the specified offset, and return the block from there.
      */
     protected abstract ByteBuffer createBlockIfNeeded(final int offset) throws IOException;
-    
+
+    /**
+     * Releases a mmap-ed buffer, which you are sure won't be used again
+     * @param buffer the buffer
+     */
+    protected abstract void releaseBuffer(ByteBuffer buffer);
+
     /**
      * Returns the BATBlock that handles the specified offset,
      *  and the relative index within it
      */
     protected abstract BATBlockAndIndex getBATBlockAndIndex(final int offset);
-    
+
     /**
      * Works out what block follows the specified one.
      */
     protected abstract int getNextBlock(final int offset);
-    
+
     /**
      * Changes the record of what block follows the specified one.
      */
     protected abstract void setNextBlock(final int offset, final int nextBlock);
-    
+
     /**
      * Finds a free block, and returns its offset.
      * This method will extend the file/stream if needed, and if doing
      *  so, allocate new FAT blocks to address the extra space.
      */
     protected abstract int getFreeBlock() throws IOException;
-    
+
     /**
-     * Creates a Detector for loops in the chain 
+     * Creates a Detector for loops in the chain
      */
     protected abstract ChainLoopDetector getChainLoopDetector() throws IOException;
-    
+
     /**
      * Used to detect if a chain has a loop in it, so
      *  we can bail out with an error rather than
-     *  spinning away for ever... 
+     *  spinning away for ever...
      */
     protected class ChainLoopDetector {
-       private boolean[] used_blocks;
+       private final boolean[] used_blocks;
        protected ChainLoopDetector(long rawSize) {
           int blkSize = getBlockStoreBlockSize();
           int numBlocks = (int)(rawSize / blkSize);
@@ -94,11 +99,11 @@ public abstract class BlockStore {
              //  blocks we've allocated for them, so are safe
              return;
           }
-          
+
           // Claiming an existing block, ensure there's no loop
           if(used_blocks[offset]) {
              throw new IllegalStateException(
-                   "Potential loop detected - Block " + offset + 
+                   "Potential loop detected - Block " + offset +
                    " was already claimed but was just requested again"
              );
           }
index 23d9b3e2b07edfdab54f2d62d1ad4ba310de5dd3..22577980a3251bbf1dc83354e72480c53a9a76e1 100644 (file)
@@ -83,8 +83,8 @@ public class POIFSFileSystem extends BlockStore
 
     private POIFSMiniStore _mini_store;
     private PropertyTable _property_table;
-    private List<BATBlock> _xbat_blocks;
-    private List<BATBlock> _bat_blocks;
+    private final List<BATBlock> _xbat_blocks;
+    private final List<BATBlock> _bat_blocks;
     private HeaderBlock _header;
     private DirectoryNode _root;
 
@@ -113,7 +113,7 @@ public class POIFSFileSystem extends BlockStore
     protected void createNewDataSource() {
         // Data needs to initially hold just the header block,
         //  a single bat block, and an empty properties section
-        long blockSize = ArithmeticUtils.mulAndCheck((long)bigBlockSize.getBigBlockSize(), (long)3);
+        long blockSize = ArithmeticUtils.mulAndCheck(bigBlockSize.getBigBlockSize(), 3L);
         _data = new ByteArrayBackedDataSource(IOUtils.safelyAllocate(blockSize, MAX_RECORD_LENGTH));
     }
 
@@ -409,7 +409,7 @@ public class POIFSFileSystem extends BlockStore
         // Ensure there's a spot in the file for it
         ByteBuffer buffer = ByteBuffer.allocate(bigBlockSize.getBigBlockSize());
         // Header isn't in BATs
-        long writeTo = ArithmeticUtils.mulAndCheck((1 + (long)offset), (long)bigBlockSize.getBigBlockSize());
+        long writeTo = ArithmeticUtils.mulAndCheck(1L + offset, bigBlockSize.getBigBlockSize());
         _data.write(buffer, writeTo);
         // All done
         return newBAT;
@@ -937,6 +937,12 @@ public class POIFSFileSystem extends BlockStore
         return _header;
     }
 
+    @Override
+    protected void releaseBuffer(ByteBuffer buffer) {
+        if (_data instanceof FileBackedDataSource) {
+            ((FileBackedDataSource)_data).releaseBuffer(buffer);
+        }
+    }
 
     private static void sanityCheckBlockCount(int block_count) throws IOException {
         if (block_count <= 0) {
index 5bd83cbdf51724f9e789ed630e4ee549d1252b8d..069ebc029270bd7f7f03e4dbfb20a9e358db9cb7 100644 (file)
@@ -37,11 +37,11 @@ import org.apache.poi.poifs.storage.HeaderBlock;
  */
 public class POIFSMiniStore extends BlockStore
 {
-    private POIFSFileSystem _filesystem;
+    private final POIFSFileSystem _filesystem;
     private POIFSStream _mini_stream;
-    private List<BATBlock>   _sbat_blocks;
-    private HeaderBlock      _header;
-    private RootProperty     _root;
+    private final List<BATBlock>   _sbat_blocks;
+    private final HeaderBlock      _header;
+    private final RootProperty     _root;
 
     POIFSMiniStore(POIFSFileSystem filesystem, RootProperty root,
                              List<BATBlock> sbats, HeaderBlock header)
@@ -93,7 +93,7 @@ public class POIFSMiniStore extends BlockStore
        if (! firstInStore) {
            try {
               return getBlockAt(offset);
-           } catch(NoSuchElementException e) {}
+           } catch(NoSuchElementException ignored) {}
        }
 
        // Need to extend the stream
@@ -259,4 +259,9 @@ public class POIFSMiniStore extends BlockStore
        // RootProperty.setSize does the sbat -> bytes conversion for us
        _filesystem._get_property_table().getRoot().setSize(blocksUsed);
     }
+
+    @Override
+    protected void releaseBuffer(ByteBuffer buffer) {
+        _filesystem.releaseBuffer(buffer);
+    }
 }
index 87bd12092dc11e979a395c82021af2144fc6fcfb..356b78c9f727934ce7c613e813bf475db820bb1a 100644 (file)
@@ -47,7 +47,7 @@ import org.apache.poi.poifs.storage.HeaderBlock;
 
 public class POIFSStream implements Iterable<ByteBuffer>
 {
-       private BlockStore blockStore;
+       private final BlockStore blockStore;
        private int startBlock;
        private OutputStream outStream;
 
@@ -140,8 +140,8 @@ public class POIFSStream implements Iterable<ByteBuffer>
    /**
     * Class that handles a streaming read of one stream
     */
-   protected class StreamBlockByteBufferIterator implements Iterator<ByteBuffer> {
-      private ChainLoopDetector loopDetector;
+   private class StreamBlockByteBufferIterator implements Iterator<ByteBuffer> {
+      private final ChainLoopDetector loopDetector;
       private int nextBlock;
 
       StreamBlockByteBufferIterator(int firstBlock) {
@@ -221,6 +221,9 @@ public class POIFSStream implements Iterable<ByteBuffer>
               nextBlock = blockStore.getNextBlock(thisBlock);
            }
 
+           if (buffer != null) {
+               blockStore.releaseBuffer(buffer);
+           }
            buffer = blockStore.createBlockIfNeeded(thisBlock);
 
            // Update pointers
index ee08de13d7827711c80df1ab11fc03bee0fe11ca..5b77db4d924746da645e56c3e505476ee04cb86f 100644 (file)
 
 package org.apache.poi.poifs.nio;
 
-import org.apache.poi.util.IOUtils;
-import org.apache.poi.util.POILogFactory;
-import org.apache.poi.util.POILogger;
-
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -30,151 +26,160 @@ import java.nio.ByteBuffer;
 import java.nio.channels.Channels;
 import java.nio.channels.FileChannel;
 import java.nio.channels.WritableByteChannel;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.IdentityHashMap;
+
+import org.apache.poi.util.IOUtils;
+import org.apache.poi.util.POILogFactory;
+import org.apache.poi.util.POILogger;
 
 /**
  * A POIFS {@link DataSource} backed by a File
  */
 public class FileBackedDataSource extends DataSource {
-   private final static POILogger logger = POILogFactory.getLogger( FileBackedDataSource.class );
-   
-   private FileChannel channel;
-   private boolean writable;
-   // remember file base, which needs to be closed too
-   private RandomAccessFile srcFile;
-   
-   // Buffers which map to a file-portion are not closed automatically when the Channel is closed
-   // therefore we need to keep the list of mapped buffers and do some ugly reflection to try to 
-   // clean the buffer during close().
-   // See https://bz.apache.org/bugzilla/show_bug.cgi?id=58480, 
-   // http://stackoverflow.com/questions/3602783/file-access-synchronized-on-java-object and
-   // http://bugs.java.com/view_bug.do?bug_id=4724038 for related discussions
-   private List<ByteBuffer> buffersToClean = new ArrayList<>();
-
-   public FileBackedDataSource(File file) throws FileNotFoundException {
-       this(newSrcFile(file, "r"), true);
-   }
-
-   public FileBackedDataSource(File file, boolean readOnly) throws FileNotFoundException {
-       this(newSrcFile(file, readOnly ? "r" : "rw"), readOnly);
-   }
-
-   public FileBackedDataSource(RandomAccessFile srcFile, boolean readOnly) {
-       this(srcFile.getChannel(), readOnly);
-       this.srcFile = srcFile;
-   }   
-   
-   public FileBackedDataSource(FileChannel channel, boolean readOnly) {
-      this.channel = channel;
-      this.writable = !readOnly;
-   }
-   
-   public boolean isWriteable() {
-       return this.writable;
-   }
-   
-   public FileChannel getChannel() {
-       return this.channel;
-   }
-
-   @Override
-   public ByteBuffer read(int length, long position) throws IOException {
-      if(position >= size()) {
-         throw new IndexOutOfBoundsException("Position " + position + " past the end of the file");
-      }
-      
-      // TODO Could we do the read-only case with MapMode.PRIVATE instead?
-      // See https://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.MapMode.html#PRIVATE
-      // Or should we have 3 modes instead of the current boolean - 
-      //  read-write, read-only, read-to-write-elsewhere? 
-      
-      // Do we read or map (for read/write)?
-      ByteBuffer dst;
-      if (writable) {
-          dst = channel.map(FileChannel.MapMode.READ_WRITE, position, length);
-
-          // remember this buffer for cleanup
-          buffersToClean.add(dst);
-      } else {
-          // allocate the buffer on the heap if we cannot map the data in directly
-          channel.position(position);
-          dst = ByteBuffer.allocate(length);
-
-          // Read the contents and check that we could read some data
-          int worked = IOUtils.readFully(channel, dst);
-          if(worked == -1) {
-              throw new IndexOutOfBoundsException("Position " + position + " past the end of the file");
-          }
-      }
-
-      // make it ready for reading
-      dst.position(0);
-
-      // All done
-      return dst;
-   }
-
-   @Override
-   public void write(ByteBuffer src, long position) throws IOException {
-      channel.write(src, position);
-   }
-
-   @Override
-   public void copyTo(OutputStream stream) throws IOException {
-      // Wrap the OutputSteam as a channel
-      try (WritableByteChannel out = Channels.newChannel(stream)) {
-          // Now do the transfer
-          channel.transferTo(0, channel.size(), out);
-      }
-   }
-
-   @Override
-   public long size() throws IOException {
-      return channel.size();
-   }
-
-   @Override
-   public void close() throws IOException {
-          // also ensure that all buffers are unmapped so we do not keep files locked on Windows
-          // We consider it a bug if a Buffer is still in use now! 
-       for(ByteBuffer buffer : buffersToClean) {
-           unmap(buffer);
-       }
-       buffersToClean.clear();
-
-       if (srcFile != null) {
-          // see http://bugs.java.com/bugdatabase/view_bug.do?bug_id=4796385
-          srcFile.close();
-      } else {
-          channel.close();
-      }
-   }
-
-   private static RandomAccessFile newSrcFile(File file, String mode) throws FileNotFoundException {
-       if(!file.exists()) {
-           throw new FileNotFoundException(file.toString());
+    private final static POILogger logger = POILogFactory.getLogger(FileBackedDataSource.class);
+
+    private final FileChannel channel;
+    private final boolean writable;
+    // remember file base, which needs to be closed too
+    private RandomAccessFile srcFile;
+
+    // Buffers which map to a file-portion are not closed automatically when the Channel is closed
+    // therefore we need to keep the list of mapped buffers and do some ugly reflection to try to
+    // clean the buffer during close().
+    // See https://bz.apache.org/bugzilla/show_bug.cgi?id=58480,
+    // http://stackoverflow.com/questions/3602783/file-access-synchronized-on-java-object and
+    // http://bugs.java.com/view_bug.do?bug_id=4724038 for related discussions
+    // https://stackoverflow.com/questions/36077641/java-when-does-direct-buffer-released
+    private final IdentityHashMap<ByteBuffer,ByteBuffer> buffersToClean = new IdentityHashMap<>();
+
+    public FileBackedDataSource(File file) throws FileNotFoundException {
+        this(newSrcFile(file, "r"), true);
+    }
+
+    public FileBackedDataSource(File file, boolean readOnly) throws FileNotFoundException {
+        this(newSrcFile(file, readOnly ? "r" : "rw"), readOnly);
+    }
+
+    public FileBackedDataSource(RandomAccessFile srcFile, boolean readOnly) {
+        this(srcFile.getChannel(), readOnly);
+        this.srcFile = srcFile;
+    }
+
+    public FileBackedDataSource(FileChannel channel, boolean readOnly) {
+        this.channel = channel;
+        this.writable = !readOnly;
+    }
+
+    public boolean isWriteable() {
+        return this.writable;
+    }
+
+    public FileChannel getChannel() {
+        return this.channel;
+    }
+
+    @Override
+    public ByteBuffer read(int length, long position) throws IOException {
+        if (position >= size()) {
+            throw new IndexOutOfBoundsException("Position " + position + " past the end of the file");
+        }
+
+        // TODO Could we do the read-only case with MapMode.PRIVATE instead?
+        // See https://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.MapMode.html#PRIVATE
+        // Or should we have 3 modes instead of the current boolean -
+        //  read-write, read-only, read-to-write-elsewhere?
+
+        // Do we read or map (for read/write)?
+        ByteBuffer dst;
+        if (writable) {
+            dst = channel.map(FileChannel.MapMode.READ_WRITE, position, length);
+
+            // remember this buffer for cleanup
+            buffersToClean.put(dst,dst);
+        } else {
+            // allocate the buffer on the heap if we cannot map the data in directly
+            channel.position(position);
+            dst = ByteBuffer.allocate(length);
+
+            // Read the contents and check that we could read some data
+            int worked = IOUtils.readFully(channel, dst);
+            if (worked == -1) {
+                throw new IndexOutOfBoundsException("Position " + position + " past the end of the file");
+            }
+        }
+
+        // make it ready for reading
+        dst.position(0);
+
+        // All done
+        return dst;
+    }
+
+    @Override
+    public void write(ByteBuffer src, long position) throws IOException {
+        channel.write(src, position);
+    }
+
+    @Override
+    public void copyTo(OutputStream stream) throws IOException {
+        // Wrap the OutputSteam as a channel
+        try (WritableByteChannel out = Channels.newChannel(stream)) {
+            // Now do the transfer
+            channel.transferTo(0, channel.size(), out);
+        }
+    }
+
+    @Override
+    public long size() throws IOException {
+        return channel.size();
+    }
+
+    public void releaseBuffer(ByteBuffer buffer) {
+        ByteBuffer previous = buffersToClean.remove(buffer);
+        if (previous != null) {
+            unmap(previous);
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        // also ensure that all buffers are unmapped so we do not keep files locked on Windows
+        // We consider it a bug if a Buffer is still in use now!
+        buffersToClean.forEach((k,v) -> unmap(v));
+        buffersToClean.clear();
+
+        if (srcFile != null) {
+            // see http://bugs.java.com/bugdatabase/view_bug.do?bug_id=4796385
+            srcFile.close();
+        } else {
+            channel.close();
+        }
+    }
+
+    private static RandomAccessFile newSrcFile(File file, String mode) throws FileNotFoundException {
+        if (!file.exists()) {
+            throw new FileNotFoundException(file.toString());
         }
         return new RandomAccessFile(file, mode);
-   }
-
-   // need to use reflection to avoid depending on the sun.nio internal API
-   // unfortunately this might break silently with newer/other Java implementations, 
-   // but we at least have unit-tests which will indicate this when run on Windows
-   private static void unmap(final ByteBuffer buffer) {
-       // not necessary for HeapByteBuffer, avoid lots of log-output on this class
-       if(buffer.getClass().getName().endsWith("HeapByteBuffer")) {
-           return;
-       }
-
-       if (CleanerUtil.UNMAP_SUPPORTED) {
-           try {
-               CleanerUtil.getCleaner().freeBuffer(buffer);
-           } catch (IOException e) {
-               logger.log(POILogger.WARN, "Failed to unmap the buffer", e);
-           }
-       } else {
-           logger.log(POILogger.DEBUG, CleanerUtil.UNMAP_NOT_SUPPORTED_REASON);
-       }
-   }
+    }
+
+    // need to use reflection to avoid depending on the sun.nio internal API
+    // unfortunately this might break silently with newer/other Java implementations,
+    // but we at least have unit-tests which will indicate this when run on Windows
+    private static void unmap(final ByteBuffer buffer) {
+        // not necessary for HeapByteBuffer, avoid lots of log-output on this class
+        if (buffer.getClass().getName().endsWith("HeapByteBuffer")) {
+            return;
+        }
+
+        if (CleanerUtil.UNMAP_SUPPORTED) {
+            try {
+                CleanerUtil.getCleaner().freeBuffer(buffer);
+            } catch (IOException e) {
+                logger.log(POILogger.WARN, "Failed to unmap the buffer", e);
+            }
+        } else {
+            logger.log(POILogger.DEBUG, CleanerUtil.UNMAP_NOT_SUPPORTED_REASON);
+        }
+    }
 }
index d7d83ae95a74eea5b89349f39e4b68c6bc06dd0a..55314125f0ed89a9debfdf9cdbd9c97434224a4d 100644 (file)
 ==================================================================== */
 package org.apache.poi.poifs.crypt;
 
+import static org.apache.poi.poifs.crypt.CryptoFunctions.getMessageDigest;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -23,7 +30,11 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.security.DigestInputStream;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
 import java.util.Iterator;
+import java.util.Random;
 
 import javax.crypto.Cipher;
 
@@ -33,8 +44,14 @@ import org.apache.poi.openxml4j.opc.OPCPackage;
 import org.apache.poi.poifs.crypt.agile.AgileDecryptor;
 import org.apache.poi.poifs.crypt.agile.AgileEncryptionHeader;
 import org.apache.poi.poifs.crypt.agile.AgileEncryptionVerifier;
-import org.apache.poi.poifs.filesystem.*;
+import org.apache.poi.poifs.filesystem.DirectoryNode;
+import org.apache.poi.poifs.filesystem.DocumentEntry;
+import org.apache.poi.poifs.filesystem.DocumentNode;
+import org.apache.poi.poifs.filesystem.Entry;
+import org.apache.poi.poifs.filesystem.POIFSFileSystem;
+import org.apache.poi.poifs.filesystem.TempFilePOIFSFileSystem;
 import org.apache.poi.util.IOUtils;
+import org.apache.poi.util.NullOutputStream;
 import org.apache.poi.util.TempFile;
 import org.apache.poi.xwpf.usermodel.XWPFDocument;
 import org.apache.poi.xwpf.usermodel.XWPFParagraph;
@@ -42,13 +59,11 @@ import org.junit.Assume;
 import org.junit.Ignore;
 import org.junit.Test;
 
-import static org.junit.Assert.*;
-
 public class TestEncryptor {
     @Test
     public void binaryRC4Encryption() throws Exception {
         // please contribute a real sample file, which is binary rc4 encrypted
-        // ... at least the output can be opened in Excel Viewer 
+        // ... at least the output can be opened in Excel Viewer
         String password = "pass";
 
         final byte[] payloadExpected;
@@ -80,7 +95,7 @@ public class TestEncryptor {
                 payloadActual = IOUtils.toByteArray(is);
             }
         }
-        
+
         assertArrayEquals(payloadExpected, payloadActual);
     }
 
@@ -167,7 +182,7 @@ public class TestEncryptor {
         // the hmacs of the file always differ, as we use PKCS5-padding to pad the bytes
         // whereas office just uses random bytes
         // byte integrityHash[] = d.getIntegrityHmacValue();
-        
+
         final EncryptionInfo infoActual = new EncryptionInfo(
               EncryptionMode.agile
             , infoExpected.getVerifier().getCipherAlgorithm()
@@ -211,7 +226,7 @@ public class TestEncryptor {
                 encPackActual = IOUtils.toByteArray(is, entry.getSize()-16);
             }
         }
-        
+
         AgileEncryptionHeader aehExpected = (AgileEncryptionHeader)infoExpected.getHeader();
         AgileEncryptionHeader aehActual = (AgileEncryptionHeader)infoActual.getHeader();
         assertArrayEquals(aehExpected.getEncryptedHmacKey(), aehActual.getEncryptedHmacKey());
@@ -219,7 +234,7 @@ public class TestEncryptor {
         assertArrayEquals(payloadExpected, payloadActual);
         assertArrayEquals(encPackExpected, encPackActual);
     }
-    
+
     @Test
     public void standardEncryption() throws Exception {
         File file = POIDataSamples.getDocumentInstance().getFile("bug53475-password-is-solrcell.docx");
@@ -247,8 +262,8 @@ public class TestEncryptor {
         final byte[] verifierExpected = d.getVerifier();
         final byte[] keySpec = d.getSecretKey().getEncoded();
         final byte[] keySalt = infoExpected.getHeader().getKeySalt();
-        
-        
+
+
         final EncryptionInfo infoActual = new EncryptionInfo(
               EncryptionMode.standard
             , infoExpected.getVerifier().getCipherAlgorithm()
@@ -257,7 +272,7 @@ public class TestEncryptor {
             , infoExpected.getHeader().getBlockSize()
             , infoExpected.getVerifier().getChainingMode()
         );
-        
+
         final Encryptor e = Encryptor.getInstance(infoActual);
         e.confirmPassword(pass, keySpec, keySalt, verifierExpected, verifierSaltExpected, null);
 
@@ -304,10 +319,10 @@ public class TestEncryptor {
 
         assertArrayEquals(payloadExpected, payloadActual);
     }
-    
+
     /**
      * Ensure we can encrypt a package that is missing the Core
-     *  Properties, eg one from dodgy versions of Jasper Reports 
+     *  Properties, eg one from dodgy versions of Jasper Reports
      * See https://github.com/nestoru/xlsxenc/ and
      * http://stackoverflow.com/questions/28593223
      */
@@ -341,7 +356,7 @@ public class TestEncryptor {
                 encBytes = baos.toByteArray();
             }
         }
-        
+
 
         try (POIFSFileSystem inpFS = new POIFSFileSystem(new ByteArrayInputStream(encBytes))) {
             // Check we can decrypt it
@@ -359,7 +374,7 @@ public class TestEncryptor {
             }
         }
     }
-    
+
     @Test
     @Ignore
     public void inPlaceRewrite() throws Exception {
@@ -369,7 +384,7 @@ public class TestEncryptor {
              InputStream fis = POIDataSamples.getPOIFSInstance().openResourceAsStream("protected_agile.docx")) {
             IOUtils.copy(fis, fos);
         }
-        
+
         try (POIFSFileSystem fs = new POIFSFileSystem(f, false)) {
 
             // decrypt the protected file - in this case it was encrypted with the default password
@@ -396,26 +411,26 @@ public class TestEncryptor {
             }
         }
     }
-    
-    
+
+
     private void listEntry(DocumentNode de, String ext, String path) throws IOException {
         path += "\\" + de.getName().replaceAll("[\\p{Cntrl}]", "_");
         System.out.println(ext+": "+path+" ("+de.getSize()+" bytes)");
-        
+
         String name = de.getName().replaceAll("[\\p{Cntrl}]", "_");
-        
+
         InputStream is = ((DirectoryNode)de.getParent()).createDocumentInputStream(de);
         FileOutputStream fos = new FileOutputStream("solr."+name+"."+ext);
         IOUtils.copy(is, fos);
         fos.close();
         is.close();
     }
-    
+
     @SuppressWarnings("unused")
     private void listDir(DirectoryNode dn, String ext, String path) throws IOException {
         path += "\\" + dn.getName().replace('\u0006', '_');
         System.out.println(ext+": "+path+" ("+dn.getStorageClsid()+")");
-        
+
         Iterator<Entry> iter = dn.getEntries();
         while (iter.hasNext()) {
             Entry ent = iter.next();
@@ -447,21 +462,21 @@ public class TestEncryptor {
         // @@ -208,6 +208,13 @@
         //      protected int invokeCipher(int posInChunk, boolean doFinal) throws GeneralSecurityException {
         //          byte plain[] = (_plainByteFlags.isEmpty()) ? null : _chunk.clone();
-        //  
+        //
         // +        if (posInChunk < 4096) {
         // +            _cipher.update(_chunk, 0, posInChunk, _chunk);
         // +            byte bla[] = { (byte)0x7A,(byte)0x0F,(byte)0x27,(byte)0xF0,(byte)0x17,(byte)0x6E,(byte)0x77,(byte)0x05,(byte)0xB9,(byte)0xDA,(byte)0x49,(byte)0xF9,(byte)0xD7,(byte)0x8E,(byte)0x03,(byte)0x1D };
         // +            System.arraycopy(bla, 0, _chunk, posInChunk-2, bla.length);
         // +            return posInChunk-2+bla.length;
         // +        }
-        // +        
+        // +
         //          int ciLen = (doFinal)
         //              ? _cipher.doFinal(_chunk, 0, posInChunk, _chunk)
         //              : _cipher.update(_chunk, 0, posInChunk, _chunk);
         //
         //      --- src/ooxml/java/org/apache/poi/poifs/crypt/agile/AgileDecryptor.java (revision 1766745)
         //      +++ src/ooxml/java/org/apache/poi/poifs/crypt/agile/AgileDecryptor.java (working copy)
-        //      
+        //
         //      @@ -300,7 +297,7 @@
         //      protected static Cipher initCipherForBlock(Cipher existing, int block, boolean lastChunk, EncryptionInfo encryptionInfo, SecretKey skey, int encryptionMode)
         //      throws GeneralSecurityException {
@@ -495,12 +510,12 @@ public class TestEncryptor {
         aehHeader.setCipherAlgorithm(CipherAlgorithm.aes128);
         aehHeader.setHashAlgorithm(HashAlgorithm.sha1);
         AgileEncryptionVerifier aehVerifier = (AgileEncryptionVerifier)eiNew.getVerifier();
-        
+
         // this cast might look strange - if the setters would be public, it will become obsolete
         // see http://stackoverflow.com/questions/5637650/overriding-protected-methods-in-java
         ((EncryptionVerifier)aehVerifier).setCipherAlgorithm(CipherAlgorithm.aes256);
         aehVerifier.setHashAlgorithm(HashAlgorithm.sha512);
-        
+
         Encryptor enc = eiNew.getEncryptor();
         enc.confirmPassword("Test001!!",
             infoOrig.getDecryptor().getSecretKey().getEncoded(),
@@ -529,10 +544,10 @@ public class TestEncryptor {
         }
 
         assertArrayEquals(epOrigBytes, epNewBytes);
-        
+
         Decryptor decReload = infoReload.getDecryptor();
         assertTrue(decReload.verifyPassword("Test001!!"));
-        
+
         AgileEncryptionHeader aehOrig = (AgileEncryptionHeader)infoOrig.getHeader();
         AgileEncryptionHeader aehReload = (AgileEncryptionHeader)infoReload.getHeader();
         assertEquals(aehOrig.getBlockSize(), aehReload.getBlockSize());
@@ -547,7 +562,7 @@ public class TestEncryptor {
         assertEquals(aehOrig.getHashAlgorithm(), aehReload.getHashAlgorithm());
         assertArrayEquals(aehOrig.getKeySalt(), aehReload.getKeySalt());
         assertEquals(aehOrig.getKeySize(), aehReload.getKeySize());
-        
+
         AgileEncryptionVerifier aevOrig = (AgileEncryptionVerifier)infoOrig.getVerifier();
         AgileEncryptionVerifier aevReload = (AgileEncryptionVerifier)infoReload.getVerifier();
         assertEquals(aevOrig.getBlockSize(), aevReload.getBlockSize());
@@ -563,11 +578,93 @@ public class TestEncryptor {
 
         AgileDecryptor adOrig = (AgileDecryptor)infoOrig.getDecryptor();
         AgileDecryptor adReload = (AgileDecryptor)infoReload.getDecryptor();
-        
+
         assertArrayEquals(adOrig.getIntegrityHmacKey(), adReload.getIntegrityHmacKey());
         // doesn't work without mocking ... see above
         // assertArrayEquals(adOrig.getIntegrityHmacValue(), adReload.getIntegrityHmacValue());
         assertArrayEquals(adOrig.getSecretKey().getEncoded(), adReload.getSecretKey().getEncoded());
         assertArrayEquals(adOrig.getVerifier(), adReload.getVerifier());
     }
+
+    @Test
+    public void smallFile() throws IOException, GeneralSecurityException {
+        // see https://stackoverflow.com/questions/61463301
+        final int tinyFileSize = 80_000_000;
+        final String pass = "s3cr3t";
+
+        File tmpFile = TempFile.createTempFile("tiny", ".bin");
+
+        // create/populate empty file
+        try (POIFSFileSystem poifs = new POIFSFileSystem();
+             FileOutputStream fos = new FileOutputStream(tmpFile)) {
+            poifs.writeFilesystem(fos);
+        }
+
+        EncryptionInfo info1 = new EncryptionInfo(EncryptionMode.agile);
+        Encryptor enc = info1.getEncryptor();
+        enc.confirmPassword(pass);
+
+        final MessageDigest md = getMessageDigest(HashAlgorithm.sha256);
+
+        // reopen as mmap-ed file
+        try (POIFSFileSystem poifs = new POIFSFileSystem(tmpFile, false)) {
+            try (OutputStream os = enc.getDataStream(poifs);
+                 RandomStream rs = new RandomStream(md)) {
+                IOUtils.copy(rs, os, tinyFileSize);
+            }
+            poifs.writeFilesystem();
+        }
+
+        final byte[] digest1 = md.digest();
+        md.reset();
+
+        // reopen and check the digest
+        try (POIFSFileSystem poifs = new POIFSFileSystem(tmpFile)) {
+            EncryptionInfo info2 = new EncryptionInfo(poifs);
+            Decryptor dec = info2.getDecryptor();
+            boolean passOk = dec.verifyPassword(pass);
+            assertTrue(passOk);
+
+            try (InputStream is = dec.getDataStream(poifs);
+                 DigestInputStream dis = new DigestInputStream(is, md);
+                 NullOutputStream nos = new NullOutputStream()) {
+                IOUtils.copy(dis, nos);
+            }
+        }
+
+        final byte[] digest2 = md.digest();
+        assertArrayEquals(digest1, digest2);
+
+        boolean isDeleted = tmpFile.delete();
+        assertTrue(isDeleted);
+    }
+
+    private static final class RandomStream extends InputStream {
+        private final Random rand = new Random();
+        private final byte[] buf = new byte[1024];
+        private final MessageDigest md;
+
+        private RandomStream(MessageDigest md) {
+            this.md = md;
+        }
+
+        @Override
+        public int read() {
+            int ret = rand.nextInt(256);
+            md.update((byte)ret);
+            return ret;
+        }
+
+        @Override
+        public int read(byte[] b, final int off, int len) {
+            for (int start = off; start-off < len; start += buf.length) {
+                rand.nextBytes(buf);
+                int copyLen = Math.min(buf.length, len-(start-off));
+                System.arraycopy(buf, 0, b, start, copyLen);
+                md.update(buf, 0, copyLen);
+            }
+            return len;
+        }
+    }
+
 }