Autopsy  4.18.0
Graphical digital forensics platform for The Sleuth Kit and other tools.
ChromeCacheExtractor.java
Go to the documentation of this file.
1 /*
2  *
3  * Autopsy Forensic Browser
4  *
5  * Copyright 2019 Basis Technology Corp.
6  *
7  * Project Contact/Architect: carrier <at> sleuthkit <dot> org
8  *
9  * Licensed under the Apache License, Version 2.0 (the "License");
10  * you may not use this file except in compliance with the License.
11  * You may obtain a copy of the License at
12  *
13  * http://www.apache.org/licenses/LICENSE-2.0
14  *
15  * Unless required by applicable law or agreed to in writing, software
16  * distributed under the License is distributed on an "AS IS" BASIS,
17  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18  * See the License for the specific language governing permissions and
19  * limitations under the License.
20  */
21 package org.sleuthkit.autopsy.recentactivity;
22 
23 import java.io.File;
24 import java.io.FileOutputStream;
25 import java.io.IOException;
26 import java.io.RandomAccessFile;
27 import java.nio.ByteBuffer;
28 import java.nio.ByteOrder;
29 import java.nio.channels.FileChannel;
30 import java.nio.charset.Charset;
31 import java.nio.file.Path;
32 import java.nio.file.Paths;
33 import java.util.ArrayList;
34 import java.util.Arrays;
35 import java.util.Collection;
36 import java.util.Collections;
37 import java.util.Comparator;
38 import java.util.HashMap;
39 import java.util.List;
40 import java.util.Map;
41 import java.util.Map.Entry;
42 import java.util.Optional;
43 import java.util.logging.Level;
44 import org.openide.util.NbBundle;
45 import org.openide.util.NbBundle.Messages;
57 import org.sleuthkit.datamodel.AbstractFile;
58 import org.sleuthkit.datamodel.Blackboard;
59 import org.sleuthkit.datamodel.BlackboardArtifact;
60 import org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE;
61 import static org.sleuthkit.datamodel.BlackboardArtifact.ARTIFACT_TYPE.TSK_WEB_CACHE;
62 import org.sleuthkit.datamodel.BlackboardAttribute;
63 import org.sleuthkit.datamodel.Content;
64 import org.sleuthkit.datamodel.DerivedFile;
65 import org.sleuthkit.datamodel.OsAccount;
66 import org.sleuthkit.datamodel.TimeUtilities;
67 import org.sleuthkit.datamodel.TskCoreException;
68 import org.sleuthkit.datamodel.TskData;
69 import org.sleuthkit.datamodel.TskException;
70 
95 final class ChromeCacheExtractor {
96 
97  private final static String DEFAULT_CACHE_PATH_STR = "default/cache"; //NON-NLS
98  private final static String BROTLI_MIMETYPE ="application/x-brotli"; //NON-NLS
99 
100  private final static long UINT32_MASK = 0xFFFFFFFFl;
101 
102  private final static int INDEXFILE_HDR_SIZE = 92*4;
103  private final static int DATAFILE_HDR_SIZE = 8192;
104 
105  private final static Logger logger = Logger.getLogger(ChromeCacheExtractor.class.getName());
106 
107  private static final String VERSION_NUMBER = "1.0.0"; //NON-NLS
108  private final String moduleName;
109 
110  private String absOutputFolderName;
111  private String relOutputFolderName;
112 
113  private final Content dataSource;
114  private final IngestJobContext context;
115  private final DataSourceIngestModuleProgress progressBar;
116  private final IngestServices services = IngestServices.getInstance();
117  private Case currentCase;
118  private FileManager fileManager;
119 
120  // A file table to cache copies of index and data_n files.
121  private final Map<String, FileWrapper> fileCopyCache = new HashMap<>();
122 
123  // A file table to cache the f_* files.
124  private final Map<String, AbstractFile> externalFilesTable = new HashMap<>();
125 
131  final class FileWrapper {
132  private final AbstractFile abstractFile;
133  private final RandomAccessFile fileCopy;
134  private final ByteBuffer byteBuffer;
135 
136  FileWrapper (AbstractFile abstractFile, RandomAccessFile fileCopy, ByteBuffer buffer ) {
137  this.abstractFile = abstractFile;
138  this.fileCopy = fileCopy;
139  this.byteBuffer = buffer;
140  }
141 
142  public RandomAccessFile getFileCopy() {
143  return fileCopy;
144  }
145  public ByteBuffer getByteBuffer() {
146  return byteBuffer;
147  }
148  AbstractFile getAbstractFile() {
149  return abstractFile;
150  }
151  }
152 
153  @NbBundle.Messages({
154  "ChromeCacheExtractor.moduleName=ChromeCacheExtractor",
155  "# {0} - module name",
156  "# {1} - row number",
157  "# {2} - table length",
158  "# {3} - cache path",
159  "ChromeCacheExtractor.progressMsg={0}: Extracting cache entry {1} of {2} entries from {3}"
160  })
161  ChromeCacheExtractor(Content dataSource, IngestJobContext context, DataSourceIngestModuleProgress progressBar ) {
162  moduleName = Bundle.ChromeCacheExtractor_moduleName();
163  this.dataSource = dataSource;
164  this.context = context;
165  this.progressBar = progressBar;
166  }
167 
168 
174  private void moduleInit() throws IngestModuleException {
175 
176  try {
177  currentCase = Case.getCurrentCaseThrows();
178  fileManager = currentCase.getServices().getFileManager();
179 
180  // Create an output folder to save any derived files
181  absOutputFolderName = RAImageIngestModule.getRAOutputPath(currentCase, moduleName, context.getJobId());
182  relOutputFolderName = Paths.get(RAImageIngestModule.getRelModuleOutputPath(currentCase, moduleName, context.getJobId())).normalize().toString();
183 
184  File dir = new File(absOutputFolderName);
185  if (dir.exists() == false) {
186  dir.mkdirs();
187  }
188  } catch (NoCurrentCaseException ex) {
189  String msg = "Failed to get current case."; //NON-NLS
190  throw new IngestModuleException(msg, ex);
191  }
192  }
193 
201  private void resetForNewCacheFolder(String cachePath) throws IngestModuleException {
202 
203  fileCopyCache.clear();
204  externalFilesTable.clear();
205 
206  String cacheAbsOutputFolderName = this.getAbsOutputFolderName() + cachePath;
207  File outDir = new File(cacheAbsOutputFolderName);
208  if (outDir.exists() == false) {
209  outDir.mkdirs();
210  }
211 
212  String cacheTempPath = RAImageIngestModule.getRATempPath(currentCase, moduleName, context.getJobId()) + cachePath;
213  File tempDir = new File(cacheTempPath);
214  if (tempDir.exists() == false) {
215  tempDir.mkdirs();
216  }
217  }
218 
225  private void cleanup () {
226 
227  for (Entry<String, FileWrapper> entry : this.fileCopyCache.entrySet()) {
228  Path tempFilePath = Paths.get(RAImageIngestModule.getRATempPath(currentCase, moduleName, context.getJobId()), entry.getKey() );
229  try {
230  entry.getValue().getFileCopy().getChannel().close();
231  entry.getValue().getFileCopy().close();
232 
233  File tmpFile = tempFilePath.toFile();
234  if (!tmpFile.delete()) {
235  tmpFile.deleteOnExit();
236  }
237  } catch (IOException ex) {
238  logger.log(Level.WARNING, String.format("Failed to delete cache file copy %s", tempFilePath.toString()), ex); //NON-NLS
239  }
240  }
241  }
242 
248  private String getAbsOutputFolderName() {
249  return absOutputFolderName;
250  }
251 
257  private String getRelOutputFolderName() {
258  return relOutputFolderName;
259  }
260 
267  void processCaches() {
268 
269  try {
270  moduleInit();
271  } catch (IngestModuleException ex) {
272  String msg = "Failed to initialize ChromeCacheExtractor."; //NON-NLS
273  logger.log(Level.SEVERE, msg, ex);
274  return;
275  }
276 
277  // Find and process the cache folders. There could be one per user
278  try {
279  // Identify each cache folder by searching for the index files in each
280  List<AbstractFile> indexFiles = findIndexFiles();
281 
282  // Process each of the cache folders
283  for (AbstractFile indexFile: indexFiles) {
284 
285  if (context.dataSourceIngestIsCancelled()) {
286  return;
287  }
288 
289  if (indexFile.getSize() > 0) {
290  processCacheFolder(indexFile);
291  }
292  }
293 
294  } catch (TskCoreException ex) {
295  String msg = "Failed to find cache index files"; //NON-NLS
296  logger.log(Level.WARNING, msg, ex);
297  }
298  }
299 
300  @Messages({
301  "ChromeCacheExtract_adding_extracted_files_msg=Chrome Cache: Adding %d extracted files for analysis.",
302  "ChromeCacheExtract_adding_artifacts_msg=Chrome Cache: Adding %d artifacts for analysis.",
303  "ChromeCacheExtract_loading_files_msg=Chrome Cache: Loading files from %s."
304  })
305 
312  private void processCacheFolder(AbstractFile indexFile) {
313 
314  String cacheFolderName = indexFile.getParentPath();
315  Optional<FileWrapper> indexFileWrapper;
316 
317  /*
318  * The first part of this method is all about finding the needed files in the cache
319  * folder and making internal copies/caches of them so that we can later process them
320  * and effeciently look them up.
321  */
322  try {
323  progressBar.progress(String.format(Bundle.ChromeCacheExtract_loading_files_msg(), cacheFolderName));
324  resetForNewCacheFolder(cacheFolderName);
325 
326  // @@@ This is little ineffecient because we later in this call search for the AbstractFile that we currently have
327  // Load the index file into the caches
328  indexFileWrapper = findDataOrIndexFile(indexFile.getName(), cacheFolderName);
329  if (!indexFileWrapper.isPresent()) {
330  String msg = String.format("Failed to find copy cache index file %s", indexFile.getUniquePath());
331  logger.log(Level.WARNING, msg);
332  return;
333  }
334 
335 
336  // load the data files into the internal cache. We do this because we often
337  // jump in between the various data_X files resolving segments
338  for (int i = 0; i < 4; i ++) {
339  Optional<FileWrapper> dataFile = findDataOrIndexFile(String.format("data_%1d",i), cacheFolderName );
340  if (!dataFile.isPresent()) {
341  return;
342  }
343  }
344 
345  // find all f_* files in a single query and load them into the cache
346  // we do this here so that it is a single query instead of hundreds of individual ones
347  findExternalFiles(cacheFolderName);
348 
349  } catch (TskCoreException | IngestModuleException ex) {
350  String msg = "Failed to find cache files in path " + cacheFolderName; //NON-NLS
351  logger.log(Level.WARNING, msg, ex);
352  return;
353  }
354 
355  /*
356  * Now the analysis begins. We parse the index file and that drives parsing entries
357  * from data_X or f_XXXX files.
358  */
359  logger.log(Level.INFO, "{0}- Now reading Cache index file from path {1}", new Object[]{moduleName, cacheFolderName }); //NON-NLS
360 
361  List<AbstractFile> derivedFiles = new ArrayList<>();
362  Collection<BlackboardArtifact> artifactsAdded = new ArrayList<>();
363 
364  ByteBuffer indexFileROBuffer = indexFileWrapper.get().getByteBuffer();
365  IndexFileHeader indexHdr = new IndexFileHeader(indexFileROBuffer);
366 
367  // seek past the header
368  indexFileROBuffer.position(INDEXFILE_HDR_SIZE);
369 
370  try {
371  /* Cycle through index and get the CacheAddress for each CacheEntry. Process each entry
372  * to extract data, add artifacts, etc. from the f_XXXX and data_x files */
373  for (int i = 0; i < indexHdr.getTableLen(); i++) {
374 
375  if (context.dataSourceIngestIsCancelled()) {
376  cleanup();
377  return;
378  }
379 
380  CacheAddress addr = new CacheAddress(indexFileROBuffer.getInt() & UINT32_MASK, cacheFolderName);
381  if (addr.isInitialized()) {
382  progressBar.progress(NbBundle.getMessage(this.getClass(),
383  "ChromeCacheExtractor.progressMsg",
384  moduleName, i, indexHdr.getTableLen(), cacheFolderName) );
385  try {
386  List<DerivedFile> addedFiles = processCacheEntry(addr, artifactsAdded);
387  derivedFiles.addAll(addedFiles);
388  }
389  catch (TskCoreException | IngestModuleException ex) {
390  logger.log(Level.WARNING, String.format("Failed to get cache entry at address %s for file with object ID %d (%s)", addr, indexFile.getId(), ex.getLocalizedMessage())); //NON-NLS
391  }
392  }
393  }
394  } catch (java.nio.BufferUnderflowException ex) {
395  logger.log(Level.WARNING, String.format("Ran out of data unexpectedly reading file %s (ObjID: %d)", indexFile.getName(), indexFile.getId()));
396  }
397 
398  if (context.dataSourceIngestIsCancelled()) {
399  cleanup();
400  return;
401  }
402 
403 
404  // notify listeners of new files and schedule for analysis
405  progressBar.progress(String.format(Bundle.ChromeCacheExtract_adding_extracted_files_msg(), derivedFiles.size()));
406  derivedFiles.forEach((derived) -> {
407  services.fireModuleContentEvent(new ModuleContentEvent(derived));
408  });
409  context.addFilesToJob(derivedFiles);
410 
411  // notify listeners about new artifacts
412  progressBar.progress(String.format(Bundle.ChromeCacheExtract_adding_artifacts_msg(), artifactsAdded.size()));
413  Blackboard blackboard = currentCase.getSleuthkitCase().getBlackboard();
414  try {
415  blackboard.postArtifacts(artifactsAdded, moduleName);
416  } catch (Blackboard.BlackboardException ex) {
417  logger.log(Level.WARNING, String.format("Failed to post cacheIndex artifacts "), ex); //NON-NLS
418  }
419 
420  cleanup();
421  }
422 
435  private List<DerivedFile> processCacheEntry(CacheAddress cacheAddress, Collection<BlackboardArtifact> artifactsAdded ) throws TskCoreException, IngestModuleException {
436 
437  List<DerivedFile> derivedFiles = new ArrayList<>();
438 
439  // get the path to the corresponding data_X file for the cache entry
440  String cacheEntryFileName = cacheAddress.getFilename();
441  String cachePath = cacheAddress.getCachePath();
442 
443  Optional<FileWrapper> cacheEntryFileOptional = findDataOrIndexFile(cacheEntryFileName, cachePath);
444  if (!cacheEntryFileOptional.isPresent()) {
445  String msg = String.format("Failed to find data file %s", cacheEntryFileName); //NON-NLS
446  throw new IngestModuleException(msg);
447  }
448 
449  // Load the entry to get its metadata, segments, etc.
450  CacheEntry cacheEntry = new CacheEntry(cacheAddress, cacheEntryFileOptional.get() );
451  List<CacheDataSegment> dataSegments = cacheEntry.getDataSegments();
452 
453  // Only process the first payload data segment in each entry
454  // first data segement has the HTTP headers, 2nd is the payload
455  if (dataSegments.size() < 2) {
456  return derivedFiles;
457  }
458  CacheDataSegment dataSegment = dataSegments.get(1);
459 
460  // Name where segment is located (could be diffrent from where entry was located)
461  String segmentFileName = dataSegment.getCacheAddress().getFilename();
462  Optional<AbstractFile> segmentFileAbstractFile = findAbstractFile(segmentFileName, cachePath);
463  if (!segmentFileAbstractFile.isPresent()) {
464  logger.log(Level.WARNING, "Error finding segment file: " + cachePath + "/" + segmentFileName); //NON-NLS
465  return derivedFiles;
466  }
467 
468  boolean isBrotliCompressed = false;
469  if (dataSegment.getType() != CacheDataTypeEnum.HTTP_HEADER && cacheEntry.isBrotliCompressed() ) {
470  isBrotliCompressed = true;
471  }
472 
473 
474  // Make artifacts around the cached item and extract data from data_X file
475  try {
476  AbstractFile cachedItemFile; //
477  /* If the cached data is in a f_XXXX file, we only need to make artifacts. */
478  if (dataSegment.isInExternalFile() ) {
479  cachedItemFile = segmentFileAbstractFile.get();
480  }
481  /* If the data is in a data_X file, we need to extract it out and then make the similar artifacts */
482  else {
483 
484  // Data segments in "data_x" files are saved in individual files and added as derived files
485  String filename = dataSegment.save();
486  String relPathname = getRelOutputFolderName() + dataSegment.getCacheAddress().getCachePath() + filename;
487 
488  // @@@ We should batch these up and do them in one big insert / transaction
489  DerivedFile derivedFile = fileManager.addDerivedFile(filename, relPathname,
490  dataSegment.getDataLength(),
491  cacheEntry.getCreationTime(), cacheEntry.getCreationTime(), cacheEntry.getCreationTime(), cacheEntry.getCreationTime(), // TBD
492  true,
493  segmentFileAbstractFile.get(),
494  "",
495  moduleName,
496  VERSION_NUMBER,
497  "",
498  TskData.EncodingType.NONE);
499 
500  derivedFiles.add(derivedFile);
501  cachedItemFile = derivedFile;
502  }
503 
504  addArtifacts(cacheEntry, cacheEntryFileOptional.get().getAbstractFile(), cachedItemFile, artifactsAdded);
505 
506  // Tika doesn't detect these types. So, make sure they have the correct MIME type */
507  if (isBrotliCompressed) {
508  cachedItemFile.setMIMEType(BROTLI_MIMETYPE);
509  cachedItemFile.save();
510  }
511 
512  } catch (TskException ex) {
513  logger.log(Level.SEVERE, "Error while trying to add an artifact", ex); //NON-NLS
514  }
515 
516  return derivedFiles;
517  }
518 
528  private void addArtifacts(CacheEntry cacheEntry, AbstractFile cacheEntryFile, AbstractFile cachedItemFile, Collection<BlackboardArtifact> artifactsAdded) throws TskCoreException {
529 
530  // Create a TSK_WEB_CACHE entry with the parent as data_X file that had the cache entry
531  Collection<BlackboardAttribute> webAttr = new ArrayList<>();
532  String url = cacheEntry.getKey() != null ? cacheEntry.getKey() : "";
533  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_URL,
534  moduleName, url));
535  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DOMAIN,
536  moduleName, NetworkUtils.extractDomain(url)));
537  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DATETIME_CREATED,
538  moduleName, cacheEntry.getCreationTime()));
539  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_HEADERS,
540  moduleName, cacheEntry.getHTTPHeaders()));
541  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH,
542  moduleName, cachedItemFile.getUniquePath()));
543  webAttr.add(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_PATH_ID,
544  moduleName, cachedItemFile.getId()));
545 
546  BlackboardArtifact webCacheArtifact = cacheEntryFile.newDataArtifact(new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_WEB_CACHE), webAttr);
547  artifactsAdded.add(webCacheArtifact);
548 
549  // Create a TSK_ASSOCIATED_OBJECT on the f_XXX or derived file file back to the CACHE entry
550  BlackboardArtifact associatedObjectArtifact = cachedItemFile.newDataArtifact(
551  new BlackboardArtifact.Type(ARTIFACT_TYPE.TSK_ASSOCIATED_OBJECT),
552  Arrays.asList(new BlackboardAttribute(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_ASSOCIATED_ARTIFACT,
553  moduleName, webCacheArtifact.getArtifactID())));
554 
555  artifactsAdded.add(associatedObjectArtifact);
556  }
557 
566  private void findExternalFiles(String cachePath) throws TskCoreException {
567 
568  List<AbstractFile> effFiles = fileManager.findFiles(dataSource, "f_%", cachePath); //NON-NLS
569  for (AbstractFile abstractFile : effFiles ) {
570  String cacheKey = cachePath + abstractFile.getName();
571  if (cachePath.equals(abstractFile.getParentPath()) && abstractFile.isFile()) {
572  // Don't overwrite an allocated version with an unallocated version
573  if (abstractFile.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC)
574  || !externalFilesTable.containsKey(cacheKey)) {
575  this.externalFilesTable.put(cacheKey, abstractFile);
576  }
577  }
578  }
579  }
588  private Optional<AbstractFile> findAbstractFile(String cacheFileName, String cacheFolderName) throws TskCoreException {
589 
590  // see if it is cached
591  String fileTableKey = cacheFolderName + cacheFileName;
592  if (cacheFileName.startsWith("f_") && externalFilesTable.containsKey(fileTableKey)) {
593  return Optional.of(externalFilesTable.get(fileTableKey));
594  }
595 
596  if (fileCopyCache.containsKey(fileTableKey)) {
597  return Optional.of(fileCopyCache.get(fileTableKey).getAbstractFile());
598  }
599 
600  List<AbstractFile> cacheFiles = currentCase.getSleuthkitCase().getFileManager().findFilesExactNameExactPath(dataSource,
601  cacheFileName, cacheFolderName);
602  if (!cacheFiles.isEmpty()) {
603  // Sort the list for consistency. Preference is:
604  // - In correct subfolder and allocated
605  // - In correct subfolder and unallocated
606  // - In incorrect subfolder and allocated
607  Collections.sort(cacheFiles, new Comparator<AbstractFile>() {
608  @Override
609  public int compare(AbstractFile file1, AbstractFile file2) {
610  try {
611  if (file1.getUniquePath().trim().endsWith(DEFAULT_CACHE_PATH_STR)
612  && ! file2.getUniquePath().trim().endsWith(DEFAULT_CACHE_PATH_STR)) {
613  return -1;
614  }
615 
616  if (file2.getUniquePath().trim().endsWith(DEFAULT_CACHE_PATH_STR)
617  && ! file1.getUniquePath().trim().endsWith(DEFAULT_CACHE_PATH_STR)) {
618  return 1;
619  }
620  } catch (TskCoreException ex) {
621  logger.log(Level.WARNING, "Error getting unique path for file with ID " + file1.getId() + " or " + file2.getId(), ex);
622  }
623 
624  if (file1.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC)
625  && ! file2.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC)) {
626  return -1;
627  }
628  if (file2.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC)
629  && ! file1.isMetaFlagSet(TskData.TSK_FS_META_FLAG_ENUM.ALLOC)) {
630  return 1;
631  }
632 
633  return Long.compare(file1.getId(), file2.getId());
634  }
635  });
636 
637  // The best match will be the first element
638  return Optional.of(cacheFiles.get(0));
639  }
640 
641  return Optional.empty();
642  }
643 
651  private List<AbstractFile> findIndexFiles() throws TskCoreException {
652  return fileManager.findFiles(dataSource, "index", DEFAULT_CACHE_PATH_STR); //NON-NLS
653  }
654 
655 
656 
668  private Optional<FileWrapper> findDataOrIndexFile(String cacheFileName, String cacheFolderName) throws TskCoreException, IngestModuleException {
669 
670  // Check if the file is already in the cache
671  String fileTableKey = cacheFolderName + cacheFileName;
672  if (fileCopyCache.containsKey(fileTableKey)) {
673  return Optional.of(fileCopyCache.get(fileTableKey));
674  }
675 
676  // Use Autopsy to get the AbstractFile
677  Optional<AbstractFile> abstractFileOptional = findAbstractFile(cacheFileName, cacheFolderName);
678  if (!abstractFileOptional.isPresent()) {
679  return Optional.empty();
680  }
681 
682  // Wrap the file so that we can get the ByteBuffer later.
683  // @@@ BC: I think this should nearly all go into FileWrapper and be done lazily and perhaps based on size.
684  // Many of the files are small enough to keep in memory for the ByteBuffer
685 
686  // write the file to disk so that we can have a memory-mapped ByteBuffer
687  AbstractFile cacheFile = abstractFileOptional.get();
688  RandomAccessFile randomAccessFile = null;
689  String tempFilePathname = RAImageIngestModule.getRATempPath(currentCase, moduleName, context.getJobId()) + cacheFolderName + cacheFile.getName(); //NON-NLS
690  try {
691  File newFile = new File(tempFilePathname);
692  ContentUtils.writeToFile(cacheFile, newFile, context::dataSourceIngestIsCancelled);
693 
694  randomAccessFile = new RandomAccessFile(tempFilePathname, "r");
695  FileChannel roChannel = randomAccessFile.getChannel();
696  ByteBuffer cacheFileROBuf = roChannel.map(FileChannel.MapMode.READ_ONLY, 0,
697  (int) roChannel.size());
698 
699  cacheFileROBuf.order(ByteOrder.nativeOrder());
700  FileWrapper cacheFileWrapper = new FileWrapper(cacheFile, randomAccessFile, cacheFileROBuf );
701 
702  if (!cacheFileName.startsWith("f_")) {
703  fileCopyCache.put(cacheFolderName + cacheFileName, cacheFileWrapper);
704  }
705 
706  return Optional.of(cacheFileWrapper);
707  }
708  catch (IOException ex) {
709 
710  try {
711  if (randomAccessFile != null) {
712  randomAccessFile.close();
713  }
714  }
715  catch (IOException ex2) {
716  logger.log(Level.SEVERE, "Error while trying to close temp file after exception.", ex2); //NON-NLS
717  }
718  String msg = String.format("Error reading/copying Chrome cache file '%s' (id=%d).", //NON-NLS
719  cacheFile.getName(), cacheFile.getId());
720  throw new IngestModuleException(msg, ex);
721  }
722  }
723 
727  final class IndexFileHeader {
728 
729  private final long magic;
730  private final int version;
731  private final int numEntries;
732  private final int numBytes;
733  private final int lastFile;
734  private final int tableLen;
735 
736  IndexFileHeader(ByteBuffer indexFileROBuf) {
737 
738  magic = indexFileROBuf.getInt() & UINT32_MASK;
739 
740  indexFileROBuf.position(indexFileROBuf.position()+2);
741 
742  version = indexFileROBuf.getShort();
743  numEntries = indexFileROBuf.getInt();
744  numBytes = indexFileROBuf.getInt();
745  lastFile = indexFileROBuf.getInt();
746 
747  indexFileROBuf.position(indexFileROBuf.position()+4); // this_id
748  indexFileROBuf.position(indexFileROBuf.position()+4); // stats cache cacheAddress
749 
750  tableLen = indexFileROBuf.getInt();
751  }
752 
753  public long getMagic() {
754  return magic;
755  }
756 
757  public int getVersion() {
758  return version;
759  }
760 
761  public int getNumEntries() {
762  return numEntries;
763  }
764 
765  public int getNumBytes() {
766  return numBytes;
767  }
768 
769  public int getLastFile() {
770  return lastFile;
771  }
772 
773  public int getTableLen() {
774  return tableLen;
775  }
776 
777  @Override
778  public String toString() {
779  StringBuilder sb = new StringBuilder();
780 
781  sb.append(String.format("Index Header:"))
782  .append(String.format("\tMagic = %x" , getMagic()) )
783  .append(String.format("\tVersion = %x" , getVersion()) )
784  .append(String.format("\tNumEntries = %x" , getNumEntries()) )
785  .append(String.format("\tNumBytes = %x" , getNumBytes()) )
786  .append(String.format("\tLastFile = %x" , getLastFile()) )
787  .append(String.format("\tTableLen = %x" , getTableLen()) );
788 
789  return sb.toString();
790  }
791  }
792 
796  enum CacheFileTypeEnum {
797  EXTERNAL,
798  RANKINGS,
799  BLOCK_256,
800  BLOCK_1K,
801  BLOCK_4K,
802  BLOCK_FILES,
803  BLOCK_ENTRIES,
804  BLOCK_EVICTED
805  }
806 
807 
808 
831  final class CacheAddress {
832  // sundry constants to parse the bit fields
833  private static final long ADDR_INITIALIZED_MASK = 0x80000000l;
834  private static final long FILE_TYPE_MASK = 0x70000000;
835  private static final long FILE_TYPE_OFFSET = 28;
836  private static final long NUM_BLOCKS_MASK = 0x03000000;
837  private static final long NUM_BLOCKS_OFFSET = 24;
838  private static final long FILE_SELECTOR_MASK = 0x00ff0000;
839  private static final long FILE_SELECTOR_OFFSET = 16;
840  private static final long START_BLOCK_MASK = 0x0000FFFF;
841  private static final long EXTERNAL_FILE_NAME_MASK = 0x0FFFFFFF;
842 
843  private final long uint32CacheAddr;
844  private final CacheFileTypeEnum fileType;
845  private final int numBlocks;
846  private final int startBlock;
847  private final String fileName;
848  private final int fileNumber;
849 
850  private final String cachePath;
851 
852 
858  CacheAddress(long uint32, String cachePath) {
859 
860  uint32CacheAddr = uint32;
861  this.cachePath = cachePath;
862 
863 
864  // analyze the
865  int fileTypeEnc = (int)(uint32CacheAddr & FILE_TYPE_MASK) >> FILE_TYPE_OFFSET;
866  fileType = CacheFileTypeEnum.values()[fileTypeEnc];
867 
868  if (isInitialized()) {
869  if (isInExternalFile()) {
870  fileNumber = (int)(uint32CacheAddr & EXTERNAL_FILE_NAME_MASK);
871  fileName = String.format("f_%06x", getFileNumber() );
872  numBlocks = 0;
873  startBlock = 0;
874  } else {
875  fileNumber = (int)((uint32CacheAddr & FILE_SELECTOR_MASK) >> FILE_SELECTOR_OFFSET);
876  fileName = String.format("data_%d", getFileNumber() );
877  numBlocks = (int)(uint32CacheAddr & NUM_BLOCKS_MASK >> NUM_BLOCKS_OFFSET);
878  startBlock = (int)(uint32CacheAddr & START_BLOCK_MASK);
879  }
880  }
881  else {
882  fileName = null;
883  fileNumber = 0;
884  numBlocks = 0;
885  startBlock = 0;
886  }
887  }
888 
889  boolean isInitialized() {
890  return ((uint32CacheAddr & ADDR_INITIALIZED_MASK) != 0);
891  }
892 
893  CacheFileTypeEnum getFileType() {
894  return fileType;
895  }
896 
901  String getFilename() {
902  return fileName;
903  }
904 
905  String getCachePath() {
906  return cachePath;
907  }
908 
909  boolean isInExternalFile() {
910  return (fileType == CacheFileTypeEnum.EXTERNAL);
911  }
912 
913  int getFileNumber() {
914  return fileNumber;
915  }
916 
917  int getStartBlock() {
918  return startBlock;
919  }
920 
921  int getNumBlocks() {
922  return numBlocks;
923  }
924 
925  int getBlockSize() {
926  switch (fileType) {
927  case RANKINGS:
928  return 36;
929  case BLOCK_256:
930  return 256;
931  case BLOCK_1K:
932  return 1024;
933  case BLOCK_4K:
934  return 4096;
935  case BLOCK_FILES:
936  return 8;
937  case BLOCK_ENTRIES:
938  return 104;
939  case BLOCK_EVICTED:
940  return 48;
941  default:
942  return 0;
943  }
944  }
945 
946  public long getUint32CacheAddr() {
947  return uint32CacheAddr;
948  }
949 
950  @Override
951  public String toString() {
952  StringBuilder sb = new StringBuilder();
953  sb.append(String.format("CacheAddr %08x : %s : filename %s",
954  uint32CacheAddr,
955  isInitialized() ? "Initialized" : "UnInitialized",
956  getFilename()));
957 
958  if ((fileType == CacheFileTypeEnum.BLOCK_256) ||
959  (fileType == CacheFileTypeEnum.BLOCK_1K) ||
960  (fileType == CacheFileTypeEnum.BLOCK_4K) ) {
961  sb.append(String.format(" (%d blocks starting at %08X)",
962  this.getNumBlocks(),
963  this.getStartBlock()
964  ));
965  }
966 
967  return sb.toString();
968  }
969 
970  }
971 
975  enum CacheDataTypeEnum {
976  HTTP_HEADER,
977  UNKNOWN,
978  };
979 
989  final class CacheDataSegment {
990 
991  private int length;
992  private final CacheAddress cacheAddress;
993  private CacheDataTypeEnum type;
994 
995  private boolean isHTTPHeaderHint;
996 
997  private FileWrapper cacheFileCopy = null;
998  private byte[] data = null;
999 
1000  private String httpResponse;
1001  private final Map<String, String> httpHeaders = new HashMap<>();
1002 
1003  CacheDataSegment(CacheAddress cacheAddress, int len) {
1004  this(cacheAddress, len, false);
1005  }
1006 
1007  CacheDataSegment(CacheAddress cacheAddress, int len, boolean isHTTPHeader ) {
1008  this.type = CacheDataTypeEnum.UNKNOWN;
1009  this.length = len;
1010  this.cacheAddress = cacheAddress;
1011  this.isHTTPHeaderHint = isHTTPHeader;
1012  }
1013 
1014  boolean isInExternalFile() {
1015  return cacheAddress.isInExternalFile();
1016  }
1017 
1018  boolean hasHTTPHeaders() {
1019  return this.type == CacheDataTypeEnum.HTTP_HEADER;
1020  }
1021 
1022  String getHTTPHeader(String key) {
1023  return this.httpHeaders.get(key);
1024  }
1025 
1031  String getHTTPHeaders() {
1032  if (!hasHTTPHeaders()) {
1033  return "";
1034  }
1035 
1036  StringBuilder sb = new StringBuilder();
1037  httpHeaders.entrySet().forEach((entry) -> {
1038  if (sb.length() > 0) {
1039  sb.append(" \n");
1040  }
1041  sb.append(String.format("%s : %s",
1042  entry.getKey(), entry.getValue()));
1043  });
1044 
1045  return sb.toString();
1046  }
1047 
1048  String getHTTPRespone() {
1049  return httpResponse;
1050  }
1051 
1057  void extract() throws TskCoreException, IngestModuleException {
1058 
1059  // do nothing if already extracted,
1060  if (data != null) {
1061  return;
1062  }
1063 
1064  // Don't extract data from external files.
1065  if (!cacheAddress.isInExternalFile()) {
1066 
1067  if (cacheAddress.getFilename() == null) {
1068  throw new TskCoreException("Cache address has no file name");
1069  }
1070 
1071  cacheFileCopy = findDataOrIndexFile(cacheAddress.getFilename(), cacheAddress.getCachePath()).get();
1072 
1073  this.data = new byte [length];
1074  ByteBuffer buf = cacheFileCopy.getByteBuffer();
1075  int dataOffset = DATAFILE_HDR_SIZE + cacheAddress.getStartBlock() * cacheAddress.getBlockSize();
1076  if (dataOffset > buf.capacity()) {
1077  return;
1078  }
1079  buf.position(dataOffset);
1080  buf.get(data, 0, length);
1081 
1082  // if this might be a HTPP header, lets try to parse it as such
1083  if ((isHTTPHeaderHint)) {
1084  String strData = new String(data);
1085  if (strData.contains("HTTP")) {
1086 
1087  // Http headers if present, are usually in frst data segment in an entry
1088  // General Parsing algo:
1089  // - Find start of HTTP header by searching for string "HTTP"
1090  // - Skip to the first 0x00 to get to the end of the HTTP response line, this makrs start of headers section
1091  // - Find the end of the header by searching for 0x00 0x00 bytes
1092  // - Extract the headers section
1093  // - Parse the headers section - each null terminated string is a header
1094  // - Each header is of the format "name: value" e.g.
1095 
1096  type = CacheDataTypeEnum.HTTP_HEADER;
1097 
1098  int startOff = strData.indexOf("HTTP");
1099  Charset charset = Charset.forName("UTF-8");
1100  boolean done = false;
1101  int i = startOff;
1102  int hdrNum = 1;
1103 
1104  while (!done) {
1105  // each header is null terminated
1106  int start = i;
1107  while (i < data.length && data[i] != 0) {
1108  i++;
1109  }
1110 
1111  // http headers are terminated by 0x00 0x00
1112  if (i == data.length || data[i+1] == 0) {
1113  done = true;
1114  }
1115 
1116  int len = (i - start);
1117  String headerLine = new String(data, start, len, charset);
1118 
1119  // first line is the http response
1120  if (hdrNum == 1) {
1121  httpResponse = headerLine;
1122  } else {
1123  int nPos = headerLine.indexOf(':');
1124  if (nPos > 0 ) {
1125  String key = headerLine.substring(0, nPos);
1126  String val= headerLine.substring(nPos+1);
1127  httpHeaders.put(key.toLowerCase(), val);
1128  }
1129  }
1130 
1131  i++;
1132  hdrNum++;
1133  }
1134  }
1135  }
1136  }
1137  }
1138 
1139  String getDataString() throws TskCoreException, IngestModuleException {
1140  if (data == null) {
1141  extract();
1142  }
1143  return new String(data);
1144  }
1145 
1146  byte[] getDataBytes() throws TskCoreException, IngestModuleException {
1147  if (data == null) {
1148  extract();
1149  }
1150  return data.clone();
1151  }
1152 
1153  int getDataLength() {
1154  return this.length;
1155  }
1156 
1157  CacheDataTypeEnum getType() {
1158  return type;
1159  }
1160 
1161  CacheAddress getCacheAddress() {
1162  return cacheAddress;
1163  }
1164 
1165 
1174  String save() throws TskCoreException, IngestModuleException {
1175  String fileName;
1176 
1177  if (cacheAddress.isInExternalFile()) {
1178  fileName = cacheAddress.getFilename();
1179  } else {
1180  fileName = String.format("%s__%08x", cacheAddress.getFilename(), cacheAddress.getUint32CacheAddr());
1181  }
1182 
1183  String filePathName = getAbsOutputFolderName() + cacheAddress.getCachePath() + fileName;
1184  save(filePathName);
1185 
1186  return fileName;
1187  }
1188 
1198  void save(String filePathName) throws TskCoreException, IngestModuleException {
1199 
1200  // Save the data to specified file
1201  if (data == null) {
1202  extract();
1203  }
1204 
1205  // Data in external files is not saved in local files
1206  if (!this.isInExternalFile()) {
1207  // write the
1208  try (FileOutputStream stream = new FileOutputStream(filePathName)) {
1209  stream.write(data);
1210  } catch (IOException ex) {
1211  throw new TskCoreException(String.format("Failed to write output file %s", filePathName), ex);
1212  }
1213  }
1214  }
1215 
1216  @Override
1217  public String toString() {
1218  StringBuilder strBuilder = new StringBuilder();
1219  strBuilder.append(String.format("\t\tData type = : %s, Data Len = %d ",
1220  this.type.toString(), this.length ));
1221 
1222  if (hasHTTPHeaders()) {
1223  String str = getHTTPHeader("content-encoding");
1224  if (str != null) {
1225  strBuilder.append(String.format("\t%s=%s", "content-encoding", str ));
1226  }
1227  }
1228 
1229  return strBuilder.toString();
1230  }
1231 
1232  }
1233 
1234 
1238  enum EntryStateEnum {
1239  ENTRY_NORMAL,
1240  ENTRY_EVICTED,
1241  ENTRY_DOOMED
1242  };
1243 
1244 
1245 // Main structure for an entry on the backing storage.
1246 //
1247 // Each entry has a key, identifying the URL the cache entry pertains to.
1248 // If the key is longer than
1249 // what can be stored on this structure, it will be extended on consecutive
1250 // blocks (adding 256 bytes each time), up to 4 blocks (1024 - 32 - 1 chars).
1251 // After that point, the whole key will be stored as a data block or external
1252 // file.
1253 //
1254 // Each entry can have upto 4 data segments
1255 //
1256 // struct EntryStore {
1257 // uint32 hash; // Full hash of the key.
1258 // CacheAddr next; // Next entry with the same hash or bucket.
1259 // CacheAddr rankings_node; // Rankings node for this entry.
1260 // int32 reuse_count; // How often is this entry used.
1261 // int32 refetch_count; // How often is this fetched from the net.
1262 // int32 state; // Current state.
1263 // uint64 creation_time;
1264 // int32 key_len;
1265 // CacheAddr long_key; // Optional cacheAddress of a long key.
1266 // int32 data_size[4]; // We can store up to 4 data streams for each
1267 // CacheAddr data_addr[4]; // entry.
1268 // uint32 flags; // Any combination of EntryFlags.
1269 // int32 pad[4];
1270 // uint32 self_hash; // The hash of EntryStore up to this point.
1271 // char key[256 - 24 * 4]; // null terminated
1272 // };
1273 
1277  final class CacheEntry {
1278 
1279  // each entry is 256 bytes. The last section of the entry, after all the other fields is a null terminated key
1280  private static final int MAX_KEY_LEN = 256-24*4;
1281 
1282  private final CacheAddress selfAddress;
1283  private final FileWrapper cacheFileCopy;
1284 
1285  private final long hash;
1286  private final CacheAddress nextAddress;
1287  private final CacheAddress rankingsNodeAddress;
1288 
1289  private final int reuseCount;
1290  private final int refetchCount;
1291  private final EntryStateEnum state;
1292 
1293  private final long creationTime;
1294  private final int keyLen;
1295 
1296  private final CacheAddress longKeyAddresses; // cacheAddress of the key, if the key is external to the entry
1297 
1298  private final int[] dataSegmentSizes;
1299  private final CacheAddress[] dataSegmentIndexFileEntries;
1300  private List<CacheDataSegment> dataSegments;
1301 
1302  private final long flags;
1303 
1304  private String key; // Key may be found within the entry or may be external
1305 
1306  CacheEntry(CacheAddress cacheAdress, FileWrapper cacheFileCopy ) throws TskCoreException {
1307  this.selfAddress = cacheAdress;
1308  this.cacheFileCopy = cacheFileCopy;
1309 
1310  ByteBuffer fileROBuf = cacheFileCopy.getByteBuffer();
1311 
1312  int entryOffset = DATAFILE_HDR_SIZE + cacheAdress.getStartBlock() * cacheAdress.getBlockSize();
1313 
1314  // reposition the buffer to the the correct offset
1315  fileROBuf.position(entryOffset);
1316 
1317  hash = fileROBuf.getInt() & UINT32_MASK;
1318 
1319  long uint32 = fileROBuf.getInt() & UINT32_MASK;
1320  nextAddress = (uint32 != 0) ? new CacheAddress(uint32, selfAddress.getCachePath()) : null;
1321 
1322  uint32 = fileROBuf.getInt() & UINT32_MASK;
1323  rankingsNodeAddress = (uint32 != 0) ? new CacheAddress(uint32, selfAddress.getCachePath()) : null;
1324 
1325  reuseCount = fileROBuf.getInt();
1326  refetchCount = fileROBuf.getInt();
1327 
1328  int stateVal = fileROBuf.getInt();
1329  if ((stateVal >= 0) && (stateVal < EntryStateEnum.values().length)) {
1330  state = EntryStateEnum.values()[stateVal];
1331  } else {
1332  throw new TskCoreException("Invalid EntryStateEnum value"); // NON-NLS
1333  }
1334  creationTime = (fileROBuf.getLong() / 1000000) - Long.valueOf("11644473600");
1335 
1336  keyLen = fileROBuf.getInt();
1337 
1338  uint32 = fileROBuf.getInt() & UINT32_MASK;
1339  longKeyAddresses = (uint32 != 0) ? new CacheAddress(uint32, selfAddress.getCachePath()) : null;
1340 
1341  dataSegments = null;
1342  dataSegmentSizes= new int[4];
1343  for (int i = 0; i < 4; i++) {
1344  dataSegmentSizes[i] = fileROBuf.getInt();
1345  }
1346  dataSegmentIndexFileEntries = new CacheAddress[4];
1347  for (int i = 0; i < 4; i++) {
1348  dataSegmentIndexFileEntries[i] = new CacheAddress(fileROBuf.getInt() & UINT32_MASK, selfAddress.getCachePath());
1349  }
1350 
1351  flags = fileROBuf.getInt() & UINT32_MASK;
1352  // skip over pad
1353  for (int i = 0; i < 4; i++) {
1354  fileROBuf.getInt();
1355  }
1356 
1357  // skip over self hash
1358  fileROBuf.getInt();
1359 
1360  // get the key
1361  if (longKeyAddresses != null) {
1362  // Key is stored outside of the entry
1363  try {
1364  CacheDataSegment data = new CacheDataSegment(longKeyAddresses, this.keyLen, true);
1365  key = data.getDataString();
1366  } catch (TskCoreException | IngestModuleException ex) {
1367  throw new TskCoreException(String.format("Failed to get external key from address %s", longKeyAddresses)); //NON-NLS
1368  }
1369  }
1370  else { // key stored within entry
1371  StringBuilder strBuilder = new StringBuilder(MAX_KEY_LEN);
1372  int keyLen = 0;
1373  while (fileROBuf.remaining() > 0 && keyLen < MAX_KEY_LEN) {
1374  char keyChar = (char)fileROBuf.get();
1375  if (keyChar == '\0') {
1376  break;
1377  }
1378  strBuilder.append(keyChar);
1379  keyLen++;
1380  }
1381 
1382  key = strBuilder.toString();
1383  }
1384  }
1385 
1386  public CacheAddress getCacheAddress() {
1387  return selfAddress;
1388  }
1389 
1390  public long getHash() {
1391  return hash;
1392  }
1393 
1394  public CacheAddress getNextCacheAddress() {
1395  return nextAddress;
1396  }
1397 
1398  public int getReuseCount() {
1399  return reuseCount;
1400  }
1401 
1402  public int getRefetchCount() {
1403  return refetchCount;
1404  }
1405 
1406  public EntryStateEnum getState() {
1407  return state;
1408  }
1409 
1410  public long getCreationTime() {
1411  return creationTime;
1412  }
1413 
1414  public long getFlags() {
1415  return flags;
1416  }
1417 
1418  public String getKey() {
1419  return key;
1420  }
1421 
1430  public List<CacheDataSegment> getDataSegments() throws TskCoreException, IngestModuleException {
1431 
1432  if (dataSegments == null) {
1433  dataSegments = new ArrayList<>();
1434  for (int i = 0; i < 4; i++) {
1435  if (dataSegmentSizes[i] > 0) {
1436  CacheDataSegment cacheData = new CacheDataSegment(dataSegmentIndexFileEntries[i], dataSegmentSizes[i], true );
1437 
1438  cacheData.extract();
1439  dataSegments.add(cacheData);
1440  }
1441  }
1442  }
1443  return dataSegments;
1444  }
1445 
1453  boolean hasHTTPHeaders() {
1454  if ((dataSegments == null) || dataSegments.isEmpty()) {
1455  return false;
1456  }
1457  return dataSegments.get(0).hasHTTPHeaders();
1458  }
1459 
1466  String getHTTPHeader(String key) {
1467  if ((dataSegments == null) || dataSegments.isEmpty()) {
1468  return null;
1469  }
1470  // First data segment has the HTTP headers, if any
1471  return dataSegments.get(0).getHTTPHeader(key);
1472  }
1473 
1479  String getHTTPHeaders() {
1480  if ((dataSegments == null) || dataSegments.isEmpty()) {
1481  return null;
1482  }
1483  // First data segment has the HTTP headers, if any
1484  return dataSegments.get(0).getHTTPHeaders();
1485  }
1486 
1495  boolean isBrotliCompressed() {
1496 
1497  if (hasHTTPHeaders() ) {
1498  String encodingHeader = getHTTPHeader("content-encoding");
1499  if (encodingHeader!= null) {
1500  return encodingHeader.trim().equalsIgnoreCase("br");
1501  }
1502  }
1503 
1504  return false;
1505  }
1506 
1507  @Override
1508  public String toString() {
1509  StringBuilder sb = new StringBuilder();
1510  sb.append(String.format("Entry = Hash: %08x, State: %s, ReuseCount: %d, RefetchCount: %d",
1511  this.hash, this.state.toString(), this.reuseCount, this.refetchCount ))
1512  .append(String.format("\n\tKey: %s, Keylen: %d",
1513  this.key, this.keyLen, this.reuseCount, this.refetchCount ))
1514  .append(String.format("\n\tCreationTime: %s",
1515  TimeUtilities.epochToTime(this.creationTime) ))
1516  .append(String.format("\n\tNext Address: %s",
1517  (nextAddress != null) ? nextAddress.toString() : "None"));
1518 
1519  for (int i = 0; i < 4; i++) {
1520  if (dataSegmentSizes[i] > 0) {
1521  sb.append(String.format("\n\tData %d: cache address = %s, Data = %s",
1522  i, dataSegmentIndexFileEntries[i].toString(),
1523  (dataSegments != null)
1524  ? dataSegments.get(i).toString()
1525  : "Data not retrived yet."));
1526  }
1527  }
1528 
1529  return sb.toString();
1530  }
1531  }
1532 }

Copyright © 2012-2021 Basis Technology. Generated on: Thu Jul 8 2021
This work is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.