View Javadoc
1   package emissary.kff;
2   
3   import emissary.core.channels.FileChannelFactory;
4   import emissary.core.channels.SeekableByteChannelFactory;
5   import emissary.test.core.junit5.UnitTest;
6   import emissary.util.io.ResourceReader;
7   
8   import org.apache.commons.compress.utils.ByteUtils;
9   import org.apache.commons.lang3.ArrayUtils;
10  import org.apache.commons.lang3.Validate;
11  import org.junit.jupiter.api.BeforeEach;
12  import org.junit.jupiter.api.Test;
13  import org.slf4j.Logger;
14  import org.slf4j.LoggerFactory;
15  
16  import java.io.IOException;
17  import java.nio.ByteBuffer;
18  import java.nio.channels.SeekableByteChannel;
19  import java.nio.file.Path;
20  import java.util.ArrayList;
21  import java.util.Arrays;
22  import java.util.Collections;
23  import java.util.List;
24  import java.util.Random;
25  import java.util.concurrent.Callable;
26  import java.util.concurrent.ExecutionException;
27  import java.util.concurrent.ExecutorService;
28  import java.util.concurrent.Executors;
29  import java.util.concurrent.Future;
30  import java.util.stream.Collectors;
31  
32  import static emissary.kff.KffFile.DEFAULT_RECORD_LENGTH;
33  import static org.junit.jupiter.api.Assertions.assertEquals;
34  import static org.junit.jupiter.api.Assertions.assertFalse;
35  import static org.junit.jupiter.api.Assertions.assertTrue;
36  import static org.junit.jupiter.api.Assertions.fail;
37  
38  class KffFileTest extends UnitTest {
39      public static final Random RANDOM = new Random();
40      private static final Logger LOGGER = LoggerFactory.getLogger(KffFileTest.class);
41  
42      private static final String ITEM_NAME = "Some_item_name";
43      private static final byte[] expectedSha1Bytes = {(byte) 0, (byte) 0, (byte) 0, (byte) 32, (byte) 103, (byte) 56, (byte) 116,
44              (byte) -114, (byte) -35, (byte) -110, (byte) -60, (byte) -29, (byte) -46, (byte) -24, (byte) 35, (byte) -119,
45              (byte) 103, (byte) 0, (byte) -8, (byte) 73};
46      private static final byte[] expectedCrcBytes = {(byte) -21, (byte) -47, (byte) 5, (byte) -96};
47      @SuppressWarnings("NonFinalStaticField")
48      private static KffFile kffFile;
49      private static final String resourcePath = new ResourceReader()
50              .getResource("emissary/kff/KffFileTest/tmp.bin").getPath();
51  
52      SeekableByteChannelFactory channelFactory = FileChannelFactory.create(Path.of(resourcePath));
53  
54      @Override
55      @BeforeEach
56      public void setUp() throws Exception {
57          kffFile = new KffFile(resourcePath, "testFilter", KffFilter.FilterType.UNKNOWN);
58          kffFile.setPreferredAlgorithm("SHA-1");
59      }
60  
61      @Test
62      void testKffFileCreation() {
63          assertEquals("testFilter", kffFile.getName());
64          kffFile.setFilterType(KffFilter.FilterType.IGNORE);
65          assertEquals(KffFilter.FilterType.IGNORE, kffFile.getFilterType());
66          assertEquals("SHA-1", kffFile.getPreferredAlgorithm());
67      }
68  
69      @Test
70      void testKffFileCheck() {
71          ChecksumResults results = new ChecksumResults();
72          results.setHash("SHA-1", expectedSha1Bytes);
73          results.setHash("CRC32", expectedCrcBytes);
74          try {
75              assertTrue(kffFile.check(ITEM_NAME, results));
76          } catch (Exception e) {
77              fail(e);
78          }
79          byte[] incorrectSha1Bytes = {(byte) 0, (byte) 0, (byte) 0, (byte) 32, (byte) 103, (byte) 56, (byte) 116,
80                  (byte) -114, (byte) -35, (byte) -110, (byte) -60, (byte) -29, (byte) -46, (byte) -24, (byte) 35, (byte) -119,
81                  (byte) 103, (byte) 0, (byte) -8, (byte) 70};
82          results = new ChecksumResults();
83          results.setHash("SHA-1", incorrectSha1Bytes);
84          try {
85              assertFalse(kffFile.check(ITEM_NAME, results));
86          } catch (Exception e) {
87              fail(e);
88          }
89      }
90  
91      /**
92       * Tests concurrent {@link KffFile#check(String, ChecksumResults)} invocations to ensure that method's thread-safety
93       */
94      @Test
95      void testConcurrentKffFileCheckCalls() throws ExecutionException, IOException, InterruptedException {
96          int EXPECTED_FAILURE_COUNT = 200;
97  
98          // the inputs we'll submit, along wth their expected KffFile.check return values
99          List<CheckTestInput> testInputs = new ArrayList<>();
100 
101         // create inputs that should be found in the file
102         parseRecordsFromBinaryFileAndAddToTestInputs(testInputs);
103         int numberOfKffEntriesInTestFile = testInputs.size();
104 
105         // create inputs that should NOT be found in the file
106         createRecordsFromRandomBytesAndAddToTestInputs(testInputs, EXPECTED_FAILURE_COUNT);
107 
108         shuffleTestInputs(testInputs);
109 
110         List<KffFileCheckTask> callables = createCallableTasksForParallelExecution(testInputs);
111 
112         logger.debug("testing {} invocations, with {} that should return true", callables.size(), numberOfKffEntriesInTestFile);
113 
114         ExecutorService executorService = null;
115         try {
116             executorService = Executors.newFixedThreadPool(10);
117             // invoke the callable tasks concurrently using the thread pool and get their results
118             List<Future<Boolean>> results = executorService.invokeAll(callables);
119             for (Future<Boolean> result : results) {
120                 assertTrue(result.get(), "kffFile.check result didn't match expectations");
121             }
122         } finally {
123             if (executorService != null) {
124                 executorService.shutdown();
125             }
126         }
127     }
128 
129     private static void createRecordsFromRandomBytesAndAddToTestInputs(List<CheckTestInput> testInputs, int recordCount) {
130         for (int i = 0; i < recordCount; i++) {
131             // build a ChecksumResults entry with random bytes, and add it to our inputs with an expected value of false
132 
133             ChecksumResults csr = buildCheckSumResultsFromRandomBytes();
134             CheckTestInput expectedFailure = new CheckTestInput(csr, false);
135             testInputs.add(expectedFailure);
136         }
137     }
138 
139     private void parseRecordsFromBinaryFileAndAddToTestInputs(List<CheckTestInput> testInputs) throws IOException {
140         int numberOfKffEntriesInTestFile;
141         // parse "known entries" from the binary input file
142         try (SeekableByteChannel byteChannel = channelFactory.create()) {
143             numberOfKffEntriesInTestFile = (int) (byteChannel.size() / DEFAULT_RECORD_LENGTH);
144             LOGGER.debug("test file contains {} known file entries", numberOfKffEntriesInTestFile);
145 
146             for (int i = 0; i < numberOfKffEntriesInTestFile; i++) {
147                 ChecksumResults csr = buildCheckSumResultsFromKffFileBytes(byteChannel, i * DEFAULT_RECORD_LENGTH);
148                 CheckTestInput expectedSuccess = new CheckTestInput(csr, true);
149                 testInputs.add(expectedSuccess);
150             }
151         }
152     }
153 
154     /**
155      * Randomly shuffles the test inputs so that expected failures are interspersed with expected successes
156      * 
157      * @param testInputs The collection of inputs
158      */
159     private static void shuffleTestInputs(List<CheckTestInput> testInputs) {
160         Collections.shuffle(testInputs);
161     }
162 
163     /**
164      * Read a raw record from the binary KFF file on disk, and converts the raw bytes into a ChecksumResults object
165      * 
166      * @param sbc channel that exposes the file contents
167      * @param startPosition offset within the channel at which the record begins
168      * @return ChecksumResults object
169      * @throws IOException if there is a problem reading bytes from the channel
170      */
171     private static ChecksumResults buildCheckSumResultsFromKffFileBytes(SeekableByteChannel sbc, int startPosition) throws IOException {
172         sbc.position(startPosition);
173         ByteBuffer buffer = ByteBuffer.wrap(new byte[DEFAULT_RECORD_LENGTH]);
174         // read the "known file" entry into the buffer
175         sbc.read(buffer);
176         // convert the raw byte[] in a ChecksumResults object
177         return buildChecksumResultsWithSha1AndCrc(buffer.array());
178     }
179 
180     /**
181      * Builds a {@link ChecksumResults} objects from 24 random bytes
182      * 
183      * @return a ChecksumResults object with contents that won't be found in the binary KFF file on disk
184      */
185     private static ChecksumResults buildCheckSumResultsFromRandomBytes() {
186         byte[] randomBytes = new byte[DEFAULT_RECORD_LENGTH];
187         RANDOM.nextBytes(randomBytes);
188         // convert the raw byte[] in a ChecksumResults object
189         return buildChecksumResultsWithSha1AndCrc(randomBytes);
190     }
191 
192     /**
193      * Creates a ChecksumResults instance from the provided bytes. The will have a SHA-1 hash value and CRC value.
194      *
195      * @param recordBytes input byte array, with expected length {@link KffFile#DEFAULT_RECORD_LENGTH}
196      * @return the constructed ChecksumBytes instance
197      */
198     private static ChecksumResults buildChecksumResultsWithSha1AndCrc(byte[] recordBytes) {
199         Validate.notNull(recordBytes, "recordBytes must not be null");
200         Validate.isTrue(recordBytes.length == DEFAULT_RECORD_LENGTH, "recordBytes must include 24 elements");
201         byte[] sha1Bytes = getSha1Bytes(recordBytes);
202         byte[] crc32Bytes = getCrc32BytesLe(recordBytes);
203         ChecksumResults csr = new ChecksumResults();
204         csr.setHash("SHA-1", sha1Bytes);
205         csr.setCrc(ByteUtils.fromLittleEndian(crc32Bytes));
206         return csr;
207     }
208 
209     /**
210      * Retrieves the SHA-1 bytes from the provided array.
211      *
212      * @param recordBytes Bytes to parse
213      * @return The SHA-1 bytes.
214      */
215     private static byte[] getSha1Bytes(byte[] recordBytes) {
216         Validate.notNull(recordBytes, "recordBytes must not be null");
217         Validate.isTrue(recordBytes.length == DEFAULT_RECORD_LENGTH, "recordBytes must include 24 elements");
218         return Arrays.copyOfRange(recordBytes, 0, DEFAULT_RECORD_LENGTH - 4);
219     }
220 
221     /**
222      * Retrieves the last 4 bytes from the input array and reverses their order from big-endian to little-endian
223      *
224      * @param recordBytes Bytes to parse
225      * @return the CRC32 bytes, in litte-endian order
226      */
227     private static byte[] getCrc32BytesLe(byte[] recordBytes) {
228         Validate.notNull(recordBytes, "recordBytes must not be null");
229         Validate.isTrue(recordBytes.length == DEFAULT_RECORD_LENGTH, "recordBytes must include 24 elements");
230         byte[] result = Arrays.copyOfRange(recordBytes, DEFAULT_RECORD_LENGTH - 4, DEFAULT_RECORD_LENGTH);
231         ArrayUtils.reverse(result);
232         return result;
233     }
234 
235     /**
236      * Convert the inputs to a list of {@link Callable} tasks we can execute in parallel
237      * 
238      * @param testInputs List of inputs
239      * @return List of Callables
240      */
241     private static List<KffFileCheckTask> createCallableTasksForParallelExecution(List<CheckTestInput> testInputs) {
242         return testInputs.stream().map(input -> new KffFileCheckTask(kffFile, input.csr, input.expectedResult))
243                 .collect(Collectors.toList());
244     }
245 
246     /**
247      * Callable to allow for evaluation of {@link KffFile#check(String, ChecksumResults)} calls in parallel
248      */
249     static class KffFileCheckTask implements Callable<Boolean> {
250         private final KffFile kffFile;
251         private final ChecksumResults csr;
252         private final Boolean expectedResult;
253 
254         KffFileCheckTask(KffFile kffFile, ChecksumResults csr, boolean expectedResult) {
255             this.kffFile = kffFile;
256             this.csr = csr;
257             this.expectedResult = expectedResult;
258         }
259 
260         /**
261          * Computes a result, or throws an exception if unable to do so.
262          *
263          * @return computed result
264          * @throws Exception if unable to compute a result
265          */
266         @Override
267         public Boolean call() throws Exception {
268             boolean actual = kffFile.check("ignored param", csr);
269             // increase this log level to view stream of executions and results
270             LOGGER.debug("expected {}, got {}", expectedResult, actual);
271             return expectedResult.equals(actual);
272         }
273     }
274 
275     /**
276      * Data Transfer Object (DTO) used for associating a {@link ChecksumResults} object and the expected result of
277      * submitting that object to a {@link KffFile#check(String, ChecksumResults)} call
278      */
279     static class CheckTestInput {
280         final ChecksumResults csr;
281         final boolean expectedResult;
282 
283         CheckTestInput(ChecksumResults csr, boolean expectedResult) {
284             this.csr = csr;
285             this.expectedResult = expectedResult;
286         }
287     }
288 }