From 368f7c30e4bf5bee7557c3d0a5656fb7652a9fa2 Mon Sep 17 00:00:00 2001 From: Charles Allen Date: Fri, 6 Mar 2015 15:49:04 -0800 Subject: [PATCH] Add Compression and File utilities for Zip and GZ handling --- .../com/metamx/common/CompressionUtils.java | 492 +++++++++++++++ .../java/com/metamx/common/FileUtils.java | 128 ++++ .../java/com/metamx/common/StreamUtils.java | 116 +++- .../metamx/common/CompressionUtilsTest.java | 568 ++++++++++++++++++ src/test/resources/loremipsum.txt | 39 ++ 5 files changed, 1334 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/metamx/common/CompressionUtils.java create mode 100644 src/main/java/com/metamx/common/FileUtils.java create mode 100644 src/test/java/com/metamx/common/CompressionUtilsTest.java create mode 100644 src/test/resources/loremipsum.txt diff --git a/src/main/java/com/metamx/common/CompressionUtils.java b/src/main/java/com/metamx/common/CompressionUtils.java new file mode 100644 index 00000000..cfb74737 --- /dev/null +++ b/src/main/java/com/metamx/common/CompressionUtils.java @@ -0,0 +1,492 @@ +/* + * Copyright 2015 Metamarkets Group Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metamx.common; + +import com.google.common.base.Predicate; +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.common.io.ByteSink; +import com.google.common.io.ByteSource; +import com.google.common.io.ByteStreams; +import com.google.common.io.Files; +import com.metamx.common.guava.CloseQuietly; +import com.metamx.common.logger.Logger; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Enumeration; +import java.util.concurrent.Callable; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +public class CompressionUtils +{ + private static final Logger log = new Logger(CompressionUtils.class); + private static final int DEFAULT_RETRY_COUNT = 3; + + public static final String GZ_SUFFIX = ".gz"; + public static final String ZIP_SUFFIX = ".zip"; + + /** + * Zip the contents of directory into the file indicated by outputZipFile. Sub directories are skipped + * + * @param directory The directory whose contents should be added to the zip in the output stream. + * @param outputZipFile The output file to write the zipped data to + * + * @return The number of bytes (uncompressed) read from the input directory. + * + * @throws IOException + */ + public static long zip(File directory, File outputZipFile) throws IOException + { + if (!isZip(outputZipFile.getName())) { + log.warn("No .zip suffix[%s], putting files from [%s] into it anyway.", outputZipFile, directory); + } + + try (final FileOutputStream out = new FileOutputStream(outputZipFile)) { + return zip(directory, out); + } + } + + /** + * Zips the contents of the input directory to the output stream. Sub directories are skipped + * + * @param directory The directory whose contents should be added to the zip in the output stream. + * @param out The output stream to write the zip data to. + * + * @return The number of bytes (uncompressed) read from the input directory. + * + * @throws IOException + */ + public static long zip(File directory, OutputStream out) throws IOException + { + if (!directory.isDirectory()) { + throw new IOException(String.format("directory[%s] is not a directory", directory)); + } + final File[] files = directory.listFiles(); + + long totalSize = 0; + try (final ZipOutputStream zipOut = new ZipOutputStream(out)) { + for (File file : files) { + log.info("Adding file[%s] with size[%,d]. Total size so far[%,d]", file, file.length(), totalSize); + if (file.length() >= Integer.MAX_VALUE) { + zipOut.finish(); + throw new IOException(String.format("file[%s] too large [%,d]", file, file.length())); + } + zipOut.putNextEntry(new ZipEntry(file.getName())); + totalSize += Files.asByteSource(file).copyTo(zipOut); + } + zipOut.closeEntry(); + } + + return totalSize; + } + + /** + * Unzip the byteSource to the output directory. If cacheLocally is true, the byteSource is cached to local disk before unzipping. + * This may cause more predictable behavior than trying to unzip a large file directly off a network stream, for example. + * * @param byteSource The ByteSource which supplies the zip data + * + * @param byteSource The ByteSource which supplies the zip data + * @param outDir The output directory to put the contents of the zip + * @param shouldRetry A predicate expression to determine if a new InputStream should be acquired from ByteSource and the copy attempted again + * @param cacheLocally A boolean flag to indicate if the data should be cached locally + * + * @return A FileCopyResult containing the result of writing the zip entries to disk + * + * @throws IOException + */ + public static FileUtils.FileCopyResult unzip( + final ByteSource byteSource, + final File outDir, + final Predicate shouldRetry, + boolean cacheLocally + ) throws IOException + { + if (!cacheLocally) { + try { + return RetryUtils.retry( + new Callable() + { + @Override + public FileUtils.FileCopyResult call() throws Exception + { + return unzip(byteSource.openStream(), outDir); + } + }, + shouldRetry, + DEFAULT_RETRY_COUNT + ); + } + catch (Exception e) { + throw Throwables.propagate(e); + } + } else { + final File tmpFile = File.createTempFile("compressionUtilZipCache", ZIP_SUFFIX); + try { + FileUtils.FileCopyResult copyResult = FileUtils.retryCopy( + byteSource, + tmpFile, + shouldRetry, + DEFAULT_RETRY_COUNT + ); + return unzip(tmpFile, outDir); + } + finally { + if (!tmpFile.delete()) { + log.warn("Could not delete zip cache at [%s]", tmpFile.toString()); + } + } + } + } + + /** + * Unzip the byteSource to the output directory. If cacheLocally is true, the byteSource is cached to local disk before unzipping. + * This may cause more predictable behavior than trying to unzip a large file directly off a network stream, for example. + * + * @param byteSource The ByteSource which supplies the zip data + * @param outDir The output directory to put the contents of the zip + * @param cacheLocally A boolean flag to indicate if the data should be cached locally + * + * @return A FileCopyResult containing the result of writing the zip entries to disk + * + * @throws IOException + */ + public static FileUtils.FileCopyResult unzip( + final ByteSource byteSource, + final File outDir, + boolean cacheLocally + ) throws IOException + { + return unzip(byteSource, outDir, FileUtils.IS_EXCEPTION, cacheLocally); + } + + /** + * Unzip the pulled file to an output directory. This is only expected to work on zips with lone files, and is not intended for zips with directory structures. + * + * @param pulledFile The file to unzip + * @param outDir The directory to store the contents of the file. + * + * @return a FileCopyResult of the files which were written to disk + * + * @throws IOException + */ + public static FileUtils.FileCopyResult unzip(final File pulledFile, final File outDir) throws IOException + { + if (!(outDir.exists() && outDir.isDirectory())) { + throw new ISE("outDir[%s] must exist and be a directory", outDir); + } + log.info("Unzipping file[%s] to [%s]", pulledFile, outDir); + final FileUtils.FileCopyResult result = new FileUtils.FileCopyResult(); + try (final ZipFile zipFile = new ZipFile(pulledFile)) { + final Enumeration enumeration = zipFile.entries(); + while (enumeration.hasMoreElements()) { + final ZipEntry entry = enumeration.nextElement(); + result.addFiles( + FileUtils.retryCopy( + new ByteSource() + { + @Override + public InputStream openStream() throws IOException + { + return new BufferedInputStream(zipFile.getInputStream(entry)); + } + }, + new File(outDir, entry.getName()), + FileUtils.IS_EXCEPTION, + DEFAULT_RETRY_COUNT + ).getFiles() + ); + } + } + return result; + } + + /** + * Unzip from the input stream to the output directory, using the entry's file name as the file name in the output directory. + * The behavior of directories in the input stream's zip is undefined. + * If possible, it is recommended to use unzip(ByteStream, File) instead + * + * @param in The input stream of the zip data + * @param outDir The directory to copy the unzipped data to + * + * @return The FileUtils.FileCopyResult containing information on all the files which were written + * + * @throws IOException + */ + public static FileUtils.FileCopyResult unzip(InputStream in, File outDir) throws IOException + { + try (final ZipInputStream zipIn = new ZipInputStream(in)) { + final FileUtils.FileCopyResult result = new FileUtils.FileCopyResult(); + ZipEntry entry; + while ((entry = zipIn.getNextEntry()) != null) { + final File file = new File(outDir, entry.getName()); + Files.asByteSink(file).writeFrom(zipIn); + result.addFile(file); + zipIn.closeEntry(); + } + return result; + } + } + + /** + * gunzip the file to the output file. + * + * @param pulledFile The source of the gz data + * @param outFile A target file to put the contents + * + * @return The result of the file copy + * + * @throws IOException + */ + public static FileUtils.FileCopyResult gunzip(final File pulledFile, File outFile) throws IOException + { + return gunzip(Files.asByteSource(pulledFile), outFile); + } + + /** + * Unzips the input stream via a gzip filter. use gunzip(ByteSource, File, Predicate) if possible + * + * @param in The input stream to run through the gunzip filter. This stream is closed + * @param outFile The file to output to + * + * @throws IOException + */ + public static FileUtils.FileCopyResult gunzip(InputStream in, File outFile) throws IOException + { + try (GZIPInputStream gzipInputStream = gzipInputStream(in)) { + Files.asByteSink(outFile).writeFrom(gzipInputStream); + return new FileUtils.FileCopyResult(outFile); + } + } + + /** + * Fixes java bug 7036144 http://bugs.java.com/bugdatabase/view_bug.do?bug_id=7036144 which affects concatenated GZip + * + * @param in The raw input stream + * + * @return A GZIPInputStream that can handle concatenated gzip streams in the input + */ + public static GZIPInputStream gzipInputStream(final InputStream in) throws IOException + { + return new GZIPInputStream( + new FilterInputStream(in) + { + @Override + public int available() + { + // Hack. Docs say available() should return an estimate, so we estimate about 1KB to work around available == 0 bug in GZIPInputStream + return 1 << 10; + } + } + ); + } + + /** + * gunzip from the source stream to the destination stream. + * + * @param in The input stream which is to be decompressed + * @param out The output stream to write to + * + * @return The number of bytes written to the output stream. + * + * @throws IOException + */ + public static long gunzip(InputStream in, OutputStream out) throws IOException + { + try (GZIPInputStream gzipInputStream = gzipInputStream(in)) { + return ByteStreams.copy(gzipInputStream, out); + } + finally { + out.close(); + } + } + + /** + * A gunzip function to store locally + * + * @param in The factory to produce input streams + * @param outFile The file to store the result into + * @param shouldRetry A predicate to indicate if the Throwable is recoverable + * + * @return The count of bytes written to outFile + */ + public static FileUtils.FileCopyResult gunzip( + final ByteSource in, + final File outFile, + Predicate shouldRetry + ) + { + return FileUtils.retryCopy( + new ByteSource() + { + @Override + public InputStream openStream() throws IOException + { + return gzipInputStream(in.openStream()); + } + }, + outFile, + shouldRetry, + DEFAULT_RETRY_COUNT + ); + } + + + /** + * Gunzip from the input stream to the output file + * + * @param in The compressed input stream to read from + * @param outFile The file to write the uncompressed results to + * + * @return A FileCopyResult of the file written + */ + public static FileUtils.FileCopyResult gunzip(final ByteSource in, File outFile) + { + return gunzip(in, outFile, FileUtils.IS_EXCEPTION); + } + + /** + * Copy inputStream to out while wrapping out in a GZIPOutputStream + * Closes both input and output + * + * @param inputStream The input stream to copy data from + * @param out The output stream to wrap in a GZIPOutputStream beore copying + * + * @return The size of the data copied + * + * @throws IOException + */ + public static long gzip(InputStream inputStream, OutputStream out) throws IOException + { + try (GZIPOutputStream outputStream = new GZIPOutputStream(out)) { + return ByteStreams.copy(inputStream, outputStream); + } + finally { + CloseQuietly.close(out); + CloseQuietly.close(inputStream); + } + } + + /** + * Gzips the input file to the output + * + * @param inFile The file to gzip + * @param outFile A target file to copy the uncompressed contents of inFile to + * @param shouldRetry Predicate on a potential throwable to determine if the copy should be attempted again. + * + * @return The result of the file copy + * + * @throws IOException + */ + public static FileUtils.FileCopyResult gzip(final File inFile, final File outFile, Predicate shouldRetry) + throws IOException + { + gzip(Files.asByteSource(inFile), Files.asByteSink(outFile), shouldRetry); + return new FileUtils.FileCopyResult(outFile); + } + + public static long gzip(final ByteSource in, final ByteSink out, Predicate shouldRetry) + throws IOException + { + return StreamUtils.retryCopy( + in, + new ByteSink() + { + @Override + public OutputStream openStream() throws IOException + { + return new GZIPOutputStream(out.openStream()); + } + }, + shouldRetry, + DEFAULT_RETRY_COUNT + ); + } + + + /** + * GZip compress the contents of inFile into outFile + * + * @param inFile The source of data + * @param outFile The destination for compressed data + * + * @return A FileCopyResult of the resulting file at outFile + * + * @throws IOException + */ + public static FileUtils.FileCopyResult gzip(final File inFile, final File outFile) throws IOException + { + return gzip(inFile, outFile, FileUtils.IS_EXCEPTION); + } + + /** + * Checks to see if fName is a valid name for a "*.zip" file + * + * @param fName The name of the file in question + * + * @return True if fName is properly named for a .zip file, false otherwise + */ + public static boolean isZip(String fName) + { + if (Strings.isNullOrEmpty(fName)) { + return false; + } + return fName.endsWith(ZIP_SUFFIX); // Technically a file named `.zip` would be fine + } + + /** + * Checks to see if fName is a valid name for a "*.gz" file + * + * @param fName The name of the file in question + * + * @return True if fName is a properly named .gz file, false otherwise + */ + public static boolean isGz(String fName) + { + if (Strings.isNullOrEmpty(fName)) { + return false; + } + return fName.endsWith(GZ_SUFFIX) && fName.length() > GZ_SUFFIX.length(); + } + + /** + * Get the file name without the .gz extension + * + * @param fname The name of the gzip file + * + * @return fname without the ".gz" extension + * + * @throws com.metamx.common.IAE if fname is not a valid "*.gz" file name + */ + public static String getGzBaseName(String fname) + { + final String reducedFname = Files.getNameWithoutExtension(fname); + if (isGz(fname) && !reducedFname.isEmpty()) { + return reducedFname; + } + throw new IAE("[%s] is not a valid gz file name", fname); + } +} diff --git a/src/main/java/com/metamx/common/FileUtils.java b/src/main/java/com/metamx/common/FileUtils.java new file mode 100644 index 00000000..f267ff9f --- /dev/null +++ b/src/main/java/com/metamx/common/FileUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright 2015 Metamarkets Group Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metamx.common; + +import com.google.common.base.Predicate; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.io.ByteSource; +import com.google.common.io.Files; + +import java.io.File; +import java.util.Arrays; +import java.util.Collection; + +public class FileUtils +{ + /** + * Useful for retry functionality that doesn't want to stop Throwables, but does want to retry on Exceptions + */ + public static final Predicate IS_EXCEPTION = new Predicate() + { + @Override + public boolean apply(Throwable input) + { + return input instanceof Exception; + } + }; + /** + * Copy input byte source to outFile. If outFile exists, it is attempted to be deleted. + * + * @param byteSource Supplier for an input stream that is to be copied. The resulting stream is closed each iteration + * @param outFile Where the file should be written to. + * @param shouldRetry Predicate indicating if an error is recoverable and should be retried. + * @param maxAttempts The maximum number of assumed recoverable attempts to try before completely failing. + * + * @throws java.lang.RuntimeException wrapping the inner exception on failure. + */ + public static FileCopyResult retryCopy( + final ByteSource byteSource, + final File outFile, + final Predicate shouldRetry, + final int maxAttempts + ) + { + try { + StreamUtils.retryCopy( + byteSource, + Files.asByteSink(outFile), + shouldRetry, + maxAttempts + ); + return new FileCopyResult(outFile); + } + catch (Exception e) { + throw Throwables.propagate(e); + } + } + + /** + * Keeps results of a file copy, including children and total size of the resultant files. + * This class is NOT thread safe. + * Child size is eagerly calculated and any modifications to the file after the child is added are not accounted for. + * As such, this result should be considered immutable, even though it has no way to force that property on the files. + */ + public static class FileCopyResult + { + private final Collection files = Lists.newArrayList(); + private long size = 0l; + + public Collection getFiles() + { + return ImmutableList.copyOf(files); + } + + // Only works for immutable children contents + public long size() + { + return size; + } + + public FileCopyResult(File... files) + { + this(files == null ? ImmutableList.of() : Arrays.asList(files)); + } + + public FileCopyResult(Collection files) + { + this.addSizedFiles(files); + } + + protected void addSizedFiles(Collection files) + { + if (files == null || files.isEmpty()) { + return; + } + long size = 0l; + for (File file : files) { + size += file.length(); + } + this.files.addAll(files); + this.size += size; + } + + public void addFiles(Collection files) + { + this.addSizedFiles(files); + } + + public void addFile(File file) + { + this.addFiles(ImmutableList.of(file)); + } + } +} diff --git a/src/main/java/com/metamx/common/StreamUtils.java b/src/main/java/com/metamx/common/StreamUtils.java index 85bd9fe1..fd8a76b5 100644 --- a/src/main/java/com/metamx/common/StreamUtils.java +++ b/src/main/java/com/metamx/common/StreamUtils.java @@ -16,8 +16,11 @@ package com.metamx.common; +import com.google.common.base.Predicate; +import com.google.common.base.Throwables; +import com.google.common.io.ByteSink; +import com.google.common.io.ByteSource; import com.google.common.io.ByteStreams; -import com.google.common.io.Closeables; import com.metamx.common.guava.CloseQuietly; import java.io.BufferedOutputStream; @@ -26,6 +29,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.concurrent.Callable; import java.util.concurrent.TimeoutException; /** @@ -35,32 +39,67 @@ public class StreamUtils // The default buffer size to use (from IOUtils) private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; - public static void copyToFileAndClose(InputStream is, File file) throws IOException + /** + * Copy from an input stream to a file (and buffer it) and close the input stream. + *

+ * It is highly recommended to use FileUtils.retryCopy whenever possible, and not use a raw `InputStream` + * + * @param is The input stream to copy bytes from. `is` is closed regardless of the copy result. + * @param file The file to copy bytes to. Any parent directories are automatically created. + * + * @return The count of bytes written to the file + * + * @throws IOException + */ + public static long copyToFileAndClose(InputStream is, File file) throws IOException { file.getParentFile().mkdirs(); try (OutputStream os = new BufferedOutputStream(new FileOutputStream(file))) { - ByteStreams.copy(is, os); + return ByteStreams.copy(is, os); } finally { CloseQuietly.close(is); } } - public static void copyToFileAndClose(InputStream is, File file, long timeout) throws IOException, TimeoutException + /** + * Copy bytes from `is` to `file` but timeout if the copy takes too long. The timeout is best effort and not + * guaranteed. Specifically, `is.read` will not be interrupted. + * + * @param is The `InputStream` to copy bytes from. It is closed regardless of copy results. + * @param file The `File` to copy bytes to + * @param timeout The timeout (in ms) of the copy. + * + * @return The size of bytes written to `file` + * + * @throws IOException + * @throws TimeoutException If `timeout` is exceeded + */ + public static long copyToFileAndClose(InputStream is, File file, long timeout) throws IOException, TimeoutException { file.getParentFile().mkdirs(); try (OutputStream os = new BufferedOutputStream(new FileOutputStream(file))) { - copyWithTimeout(is, os, timeout); + return copyWithTimeout(is, os, timeout); } finally { CloseQuietly.close(is); } } - public static void copyAndClose(InputStream is, OutputStream os) throws IOException + /** + * Copy from `is` to `os` and close the streams regardless of the result. + * + * @param is The `InputStream` to copy results from. It is closed + * @param os The `OutputStream` to copy results to. It is closed + * + * @return The count of bytes written to `os` + * + * @throws IOException + */ + public static long copyAndClose(InputStream is, OutputStream os) throws IOException { try { - ByteStreams.copy(is, os); + return ByteStreams.copy(is, os); } finally { CloseQuietly.close(is); @@ -68,17 +107,76 @@ public static void copyAndClose(InputStream is, OutputStream os) throws IOExcept } } - public static void copyWithTimeout(InputStream is, OutputStream os, long timeout) throws IOException, TimeoutException + /** + * Copy from the input stream to the output stream and tries to exit if the copy exceeds the timeout. The timeout + * is best effort. Specifically, `is.read` will not be interrupted. + * + * @param is The input stream to read bytes from. + * @param os The output stream to write bytes to. + * @param timeout The timeout (in ms) for the copy operation + * + * @return The total size of bytes written to `os` + * + * @throws IOException + * @throws TimeoutException If `tiemout` is exceeded + */ + public static long copyWithTimeout(InputStream is, OutputStream os, long timeout) throws IOException, TimeoutException { byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; int n = 0; long startTime = System.currentTimeMillis(); - + long size = 0l; while (-1 != (n = is.read(buffer))) { if (System.currentTimeMillis() - startTime > timeout) { throw new TimeoutException(String.format("Copy time has exceeded %,d millis", timeout)); } os.write(buffer, 0, n); + size += n; + } + return size; + } + + /** + * Retry copy attempts from input stream to output stream. Does *not* check to make sure data was intact during the transfer + * + * @param byteSource Supplier for input streams to copy from. The stream is closed on every retry. + * @param byteSink Supplier for output streams. The stream is closed on every retry. + * @param shouldRetry Predicate to determine if the throwable is recoverable for a retry + * @param maxAttempts Maximum number of retries before failing + */ + public static long retryCopy( + final ByteSource byteSource, + final ByteSink byteSink, + final Predicate shouldRetry, + final int maxAttempts + ) + { + try { + return RetryUtils.retry( + new Callable() + { + @Override + public Long call() throws Exception + { + InputStream inputStream = null; + OutputStream outputStream = null; + try { + inputStream = byteSource.openStream(); + outputStream = byteSink.openStream(); + return ByteStreams.copy(inputStream, outputStream); + } + finally { + CloseQuietly.close(inputStream); + CloseQuietly.close(outputStream); + } + } + }, + shouldRetry, + maxAttempts + ); + } + catch (Exception e) { + throw Throwables.propagate(e); } } } diff --git a/src/test/java/com/metamx/common/CompressionUtilsTest.java b/src/test/java/com/metamx/common/CompressionUtilsTest.java new file mode 100644 index 00000000..f99fde95 --- /dev/null +++ b/src/test/java/com/metamx/common/CompressionUtilsTest.java @@ -0,0 +1,568 @@ +/* + * Copyright 2015 Metamarkets Group Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.metamx.common; + +import com.google.common.base.Predicates; +import com.google.common.base.Throwables; +import com.google.common.io.ByteSource; +import com.google.common.io.ByteStreams; +import com.google.common.io.Files; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Iterator; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +public class CompressionUtilsTest +{ + private static final String content; + private static final byte[] expected; + private static final byte[] gzBytes; + + static { + final StringBuilder builder = new StringBuilder(); + try (InputStream stream = CompressionUtilsTest.class.getClassLoader().getResourceAsStream("loremipsum.txt")) { + final Iterator it = new java.util.Scanner(stream).useDelimiter(Pattern.quote("|")); + while (it.hasNext()) { + builder.append(it.next()); + } + } + catch (IOException e) { + throw Throwables.propagate(e); + } + content = builder.toString(); + expected = StringUtils.toUtf8(content); + + final ByteArrayOutputStream gzByteStream = new ByteArrayOutputStream(expected.length); + try (GZIPOutputStream outputStream = new GZIPOutputStream(gzByteStream)) { + try (ByteArrayInputStream in = new ByteArrayInputStream(expected)) { + ByteStreams.copy(in, outputStream); + } + } + catch (IOException e) { + throw Throwables.propagate(e); + } + gzBytes = gzByteStream.toByteArray(); + } + + private File testDir; + private File testFile; + + @Before + public void setUp() throws IOException + { + testDir = Files.createTempDir(); + testDir.deleteOnExit(); + testFile = Paths.get(testDir.getAbsolutePath(), "test.dat").toFile(); + testFile.deleteOnExit(); + try (OutputStream outputStream = new FileOutputStream(testFile)) { + outputStream.write(StringUtils.toUtf8(content)); + } + Assert.assertTrue(testFile.getParentFile().equals(testDir)); + } + + @After + public void tearDown() throws IOException + { + deleteFile(testFile); + deleteFile(testDir); + } + + private static void deleteFile(final File file) throws IOException + { + if (file.exists()) { + if (!file.delete()) { + throw new IOException(String.format("Could not delete file [%s]", file.getAbsolutePath())); + } + } + } + + public static void assertGoodDataStream(InputStream stream) throws IOException + { + try (final ByteArrayOutputStream bos = new ByteArrayOutputStream(expected.length)) { + ByteStreams.copy(stream, bos); + Assert.assertArrayEquals(expected, bos.toByteArray()); + } + } + + @Test + public void testGoodGzNameResolution() + { + Assert.assertEquals("foo", CompressionUtils.getGzBaseName("foo.gz")); + } + + @Test(expected = IAE.class) + public void testBadGzName() + { + CompressionUtils.getGzBaseName("foo"); + } + + + @Test(expected = IAE.class) + public void testBadShortGzName() + { + CompressionUtils.getGzBaseName(".gz"); + } + + @Test + public void testGoodZipCompressUncompress() throws IOException + { + final File zipFile = File.createTempFile("compressionUtilTest", ".zip"); + zipFile.deleteOnExit(); + try { + CompressionUtils.zip(testDir, zipFile); + final File newDir = Files.createTempDir(); + try { + CompressionUtils.unzip(zipFile, newDir); + final Path newPath = Paths.get(newDir.getAbsolutePath(), testFile.getName()); + Assert.assertTrue(newPath.toFile().exists()); + try (final FileInputStream inputStream = new FileInputStream(newPath.toFile())) { + assertGoodDataStream(inputStream); + } + } + finally { + final File[] files = newDir.listFiles(); + if (files != null) { + for (final File file : files) { + deleteFile(file); + } + } + deleteFile(newDir); + } + } + finally { + deleteFile(zipFile); + } + } + + + @Test + public void testGoodZipCompressUncompressWithLocalCopy() throws IOException + { + final File zipFile = File.createTempFile("compressionUtilTest", ".zip"); + zipFile.deleteOnExit(); + try { + CompressionUtils.zip(testDir, zipFile); + final File newDir = Files.createTempDir(); + try { + CompressionUtils.unzip( + new ByteSource() + { + @Override + public InputStream openStream() throws IOException + { + return new FileInputStream(zipFile); + } + }, + newDir, + true + ); + final Path newPath = Paths.get(newDir.getAbsolutePath(), testFile.getName()); + Assert.assertTrue(newPath.toFile().exists()); + try (final FileInputStream inputStream = new FileInputStream(newPath.toFile())) { + assertGoodDataStream(inputStream); + } + } + finally { + final File[] files = newDir.listFiles(); + if (files != null) { + for (final File file : files) { + deleteFile(file); + } + } + deleteFile(newDir); + } + } + finally { + deleteFile(zipFile); + } + } + + @Test + public void testGoodGZCompressUncompressToFile() throws Exception + { + final File gzFile = Paths.get(testDir.getAbsolutePath(), testFile.getName() + ".gz").toFile(); + Assert.assertFalse(gzFile.exists()); + try { + CompressionUtils.gzip(testFile, gzFile); + Assert.assertTrue(gzFile.exists()); + try (final InputStream inputStream = new GZIPInputStream(new FileInputStream(gzFile))) { + assertGoodDataStream(inputStream); + } + testFile.delete(); + Assert.assertFalse(testFile.exists()); + CompressionUtils.gunzip(gzFile, testFile); + Assert.assertTrue(testFile.exists()); + try (final InputStream inputStream = new FileInputStream(testFile)) { + assertGoodDataStream(inputStream); + } + } + finally { + deleteFile(gzFile); + } + } + + @Test + public void testGoodZipStream() throws IOException + { + final File zipFile = File.createTempFile("compressionUtilTest", ".zip"); + zipFile.deleteOnExit(); + try { + CompressionUtils.zip(testDir, new FileOutputStream(zipFile)); + final File newDir = Files.createTempDir(); + try { + CompressionUtils.unzip(new FileInputStream(zipFile), newDir); + final Path newPath = Paths.get(newDir.getAbsolutePath(), testFile.getName()); + Assert.assertTrue(newPath.toFile().exists()); + try (final FileInputStream inputStream = new FileInputStream(newPath.toFile())) { + assertGoodDataStream(inputStream); + } + } + finally { + final File[] files = newDir.listFiles(); + if (files != null) { + for (final File file : files) { + deleteFile(file); + } + } + deleteFile(newDir); + } + } + finally { + deleteFile(zipFile); + } + } + + + @Test + public void testGoodGzipByteSource() throws IOException + { + final File gzFile = Paths.get(testDir.getAbsolutePath(), testFile.getName() + ".gz").toFile(); + Assert.assertFalse(gzFile.exists()); + try { + CompressionUtils.gzip(Files.asByteSource(testFile), Files.asByteSink(gzFile), Predicates.alwaysTrue()); + Assert.assertTrue(gzFile.exists()); + try (final InputStream inputStream = CompressionUtils.gzipInputStream(new FileInputStream(gzFile))) { + assertGoodDataStream(inputStream); + } + if (!testFile.delete()) { + throw new IOException(String.format("Unable to delete file [%s]", testFile.getAbsolutePath())); + } + Assert.assertFalse(testFile.exists()); + CompressionUtils.gunzip(Files.asByteSource(gzFile), testFile); + Assert.assertTrue(testFile.exists()); + try (final InputStream inputStream = new FileInputStream(testFile)) { + assertGoodDataStream(inputStream); + } + } + finally { + deleteFile(gzFile); + } + } + + @Test + public void testGoodGZStream() throws IOException + { + final File gzFile = Paths.get(testDir.getAbsolutePath(), testFile.getName() + ".gz").toFile(); + Assert.assertFalse(gzFile.exists()); + try { + CompressionUtils.gzip(new FileInputStream(testFile), new FileOutputStream(gzFile)); + Assert.assertTrue(gzFile.exists()); + try (final InputStream inputStream = new GZIPInputStream(new FileInputStream(gzFile))) { + assertGoodDataStream(inputStream); + } + if (!testFile.delete()) { + throw new IOException(String.format("Unable to delete file [%s]", testFile.getAbsolutePath())); + } + Assert.assertFalse(testFile.exists()); + CompressionUtils.gunzip(new FileInputStream(gzFile), testFile); + Assert.assertTrue(testFile.exists()); + try (final InputStream inputStream = new FileInputStream(testFile)) { + assertGoodDataStream(inputStream); + } + } + finally { + deleteFile(gzFile); + } + } + + private static class ZeroRemainingInputStream extends FilterInputStream + { + private final AtomicInteger pos = new AtomicInteger(0); + + protected ZeroRemainingInputStream(InputStream in) + { + super(in); + } + + @Override + public synchronized void reset() throws IOException + { + super.reset(); + pos.set(0); + } + + @Override + public int read(byte b[]) throws IOException + { + final int len = Math.min(b.length, gzBytes.length - pos.get() % gzBytes.length); + pos.addAndGet(len); + return read(b, 0, len); + } + + @Override + public int read() throws IOException + { + pos.incrementAndGet(); + return super.read(); + } + + @Override + public int read(byte b[], int off, int len) throws IOException + { + final int l = Math.min(len, gzBytes.length - pos.get() % gzBytes.length); + pos.addAndGet(l); + return super.read(b, off, l); + } + + @Override + public int available() throws IOException + { + return 0; + } + } + + + @Test + // Sanity check to make sure the test class works as expected + public void testZeroRemainingInputStream() throws IOException + { + try (OutputStream outputStream = new FileOutputStream(testFile)) { + Assert.assertEquals( + gzBytes.length, + ByteStreams.copy( + new ZeroRemainingInputStream(new ByteArrayInputStream(gzBytes)), + outputStream + ) + ); + Assert.assertEquals( + gzBytes.length, + ByteStreams.copy( + new ZeroRemainingInputStream(new ByteArrayInputStream(gzBytes)), + outputStream + ) + ); + Assert.assertEquals( + gzBytes.length, + ByteStreams.copy( + new ZeroRemainingInputStream(new ByteArrayInputStream(gzBytes)), + outputStream + ) + ); + } + Assert.assertEquals(gzBytes.length * 3, testFile.length()); + try (InputStream inputStream = new ZeroRemainingInputStream(new FileInputStream(testFile))) { + for (int i = 0; i < 3; ++i) { + final byte[] bytes = new byte[gzBytes.length]; + Assert.assertEquals(bytes.length, inputStream.read(bytes)); + Assert.assertArrayEquals( + String.format("Failed on range %d", i), + gzBytes, + bytes + ); + } + } + } + + // If this ever passes, er... fails to fail... then the bug is fixed + @Test(expected = java.lang.AssertionError.class) + // http://bugs.java.com/bugdatabase/view_bug.do?bug_id=7036144 + public void testGunzipBug() throws IOException + { + final ByteArrayOutputStream tripleGzByteStream = new ByteArrayOutputStream(gzBytes.length * 3); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + try (final InputStream inputStream = new GZIPInputStream( + new ZeroRemainingInputStream( + new ByteArrayInputStream( + tripleGzByteStream.toByteArray() + ) + ) + )) { + try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(expected.length * 3)) { + Assert.assertEquals( + "Read terminated too soon (bug 7036144)", + expected.length * 3, + ByteStreams.copy(inputStream, outputStream) + ); + final byte[] found = outputStream.toByteArray(); + Assert.assertEquals(expected.length * 3, found.length); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 0, expected.length * 1)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 1, expected.length * 2)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 2, expected.length * 3)); + } + } + } + + @Test + // http://bugs.java.com/bugdatabase/view_bug.do?bug_id=7036144 + public void testGunzipBugworkarround() throws IOException + { + testFile.delete(); + Assert.assertFalse(testFile.exists()); + + final ByteArrayOutputStream tripleGzByteStream = new ByteArrayOutputStream(gzBytes.length * 3); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + + final ByteSource inputStreamFactory = new ByteSource() + { + @Override + public InputStream openStream() throws IOException + { + return new ZeroRemainingInputStream(new ByteArrayInputStream(tripleGzByteStream.toByteArray())); + } + }; + + Assert.assertEquals((long) (expected.length * 3), CompressionUtils.gunzip(inputStreamFactory, testFile).size()); + + try (final InputStream inputStream = new FileInputStream(testFile)) { + try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(expected.length * 3)) { + Assert.assertEquals( + "Read terminated too soon (7036144)", + expected.length * 3, + ByteStreams.copy(inputStream, outputStream) + ); + final byte[] found = outputStream.toByteArray(); + Assert.assertEquals(expected.length * 3, found.length); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 0, expected.length * 1)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 1, expected.length * 2)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 2, expected.length * 3)); + } + } + } + + + @Test + // http://bugs.java.com/bugdatabase/view_bug.do?bug_id=7036144 + public void testGunzipBugStreamWorkarround() throws IOException + { + + final ByteArrayOutputStream tripleGzByteStream = new ByteArrayOutputStream(gzBytes.length * 3); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + tripleGzByteStream.write(gzBytes); + + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(expected.length * 3)) { + Assert.assertEquals( + expected.length * 3, + CompressionUtils.gunzip( + new ZeroRemainingInputStream( + new ByteArrayInputStream(tripleGzByteStream.toByteArray()) + ), bos + ) + ); + final byte[] found = bos.toByteArray(); + Assert.assertEquals(expected.length * 3, found.length); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 0, expected.length * 1)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 1, expected.length * 2)); + Assert.assertArrayEquals(expected, Arrays.copyOfRange(found, expected.length * 2, expected.length * 3)); + } + } + + @Test + public void testZipName() throws IOException + { + final File tmpDir = Files.createTempDir(); + tmpDir.deleteOnExit(); + final File file = Paths.get(tmpDir.getAbsolutePath(), ".zip").toFile(); + final Path unzipPath = Paths.get(tmpDir.getAbsolutePath(), "test.dat"); + file.delete(); + Assert.assertFalse(file.exists()); + Assert.assertFalse(unzipPath.toFile().exists()); + try { + CompressionUtils.zip(testDir, file); + Assert.assertTrue(file.exists()); + CompressionUtils.unzip(file, tmpDir); + Assert.assertTrue(unzipPath.toFile().exists()); + try (final FileInputStream inputStream = new FileInputStream(unzipPath.toFile())) { + assertGoodDataStream(inputStream); + } + } + finally { + file.delete(); + unzipPath.toFile().delete(); + tmpDir.delete(); + } + } + + @Test + public void testNewFileDoesntCreateFile() + { + final File tmpFile = new File(testDir, "fofooofodshfudhfwdjkfwf.dat"); + Assert.assertFalse(tmpFile.exists()); + } + + @Test + public void testGoodGzipName() + { + Assert.assertEquals("foo", CompressionUtils.getGzBaseName("foo.gz")); + } + + @Test + public void testGoodGzipNameWithPath() + { + Assert.assertEquals("foo", CompressionUtils.getGzBaseName("/tar/ball/baz/bock/foo.gz")); + } + + @Test(expected = IAE.class) + public void testBadShortName() + { + CompressionUtils.getGzBaseName(".gz"); + } + + @Test(expected = IAE.class) + public void testBadName() + { + CompressionUtils.getGzBaseName("BANANAS"); + } + + @Test(expected = IAE.class) + public void testBadNameWithPath() + { + CompressionUtils.getGzBaseName("/foo/big/.gz"); + } +} diff --git a/src/test/resources/loremipsum.txt b/src/test/resources/loremipsum.txt new file mode 100644 index 00000000..699c202b --- /dev/null +++ b/src/test/resources/loremipsum.txt @@ -0,0 +1,39 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus suscipit a est id maximus. Vivamus venenatis turpis eget ullamcorper tincidunt. Nam venenatis lorem ac condimentum imperdiet. Curabitur accumsan orci sed mollis elementum. Morbi quam augue, porttitor non lorem a, sollicitudin efficitur mi. Nunc et nulla mauris. Phasellus volutpat dignissim congue. Maecenas hendrerit, dolor sit amet rhoncus maximus, dolor tellus auctor purus, id molestie quam quam vitae arcu. Nunc nec fringilla ante. + +Duis at scelerisque est. Sed eget interdum turpis, nec pellentesque odio. Cras eu dapibus dolor, malesuada iaculis arcu. Integer placerat leo id convallis vestibulum. Aliquam dictum velit diam, in commodo libero vehicula at. Fusce vulputate, purus ac condimentum vulputate, odio ex commodo sem, in tincidunt urna diam porta justo. Sed non malesuada libero. Curabitur eget neque eu lorem porttitor cursus. Vestibulum massa nisi, eleifend sit amet faucibus a, interdum sed risus. Donec ultricies leo sed feugiat tincidunt. Pellentesque urna enim, pellentesque eget fringilla id, vestibulum ac magna. Vivamus a rhoncus purus, aliquam hendrerit ante. + +Curabitur erat ex, efficitur nec sollicitudin sed, venenatis ut eros. In elementum imperdiet sem quis venenatis. Duis id posuere ante. Integer laoreet dui ligula, ac fringilla sapien egestas at. Donec at venenatis arcu, eu imperdiet odio. Phasellus felis nisi, suscipit maximus convallis vitae, rutrum a quam. Donec in fringilla mauris, ac varius ipsum. Aliquam suscipit metus sit amet porta suscipit. Donec id vulputate nisi. Proin non consequat ipsum. Fusce sagittis, mi sit amet gravida pulvinar, neque ante posuere magna, eu placerat purus ligula a ex. Nam aliquam suscipit mi eu auctor. Nunc imperdiet ipsum quis lectus gravida, id accumsan orci pretium. + +Phasellus mollis ac tortor a tempus. Integer sed augue convallis, dictum libero a, consectetur augue. Sed vitae lectus eros. Sed vel augue dignissim, vulputate diam viverra, hendrerit neque. Nam id magna vehicula, bibendum nisi id, mattis turpis. Cras consequat at metus at volutpat. Maecenas sed eleifend felis, at euismod nulla. Donec tincidunt ex a lacus tincidunt, rhoncus suscipit ex tincidunt. Sed sed justo nec orci pellentesque sollicitudin. + +Donec tortor ligula, mollis vel euismod ut, semper vitae felis. Curabitur eget est ac mauris ullamcorper facilisis. Integer condimentum, arcu eu viverra interdum, diam purus fringilla felis, non gravida ipsum lectus ac purus. Phasellus consectetur, odio at condimentum ultrices, massa ligula tempus massa, id tempus quam nisl scelerisque odio. Cras elementum mattis turpis venenatis euismod. Quisque vestibulum eros nulla, sit amet pellentesque erat rutrum ac. Pellentesque eu dolor id tortor ullamcorper consectetur. Curabitur venenatis sapien vitae nulla euismod dignissim. Donec viverra, nisl sit amet faucibus mattis, felis mi euismod erat, sed blandit erat justo ac nisi. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin bibendum molestie lorem eget aliquet. Nulla hendrerit ligula non diam finibus, vitae placerat metus condimentum. In eget quam ullamcorper est facilisis tempus eget nec magna. Nulla nec metus non nisl semper luctus. Etiam tortor nisl, mattis vitae ornare vel, facilisis at metus. Fusce lobortis lorem sem, sed porttitor nisl gravida non. Nullam massa nibh, pellentesque nec porttitor non, vestibulum at mauris. Praesent condimentum arcu ligula, condimentum tristique nulla euismod a. Aliquam urna massa, laoreet et lacus in, eleifend euismod eros. In dignissim elit quis purus vehicula viverra. Etiam in molestie lectus. Nunc convallis nec erat fringilla consequat. Pellentesque nec massa sed mauris venenatis lacinia. + +Nullam commodo efficitur magna, nec dictum justo maximus iaculis. Ut pellentesque urna sit amet iaculis viverra. Duis a tristique nisi. Sed cursus vitae felis eu tincidunt. Vestibulum euismod porttitor libero, eu euismod magna feugiat at. In egestas orci finibus nulla faucibus cursus. Praesent at placerat nulla. Duis nec molestie velit. Nulla facilisi. Donec at ex ut ex maximus fringilla et ac mi. Proin massa nibh, pellentesque vitae dolor eu, aliquet tempus libero. Donec vel turpis lorem. + +Quisque ac velit a turpis semper gravida. Vestibulum at elit non dui pulvinar porta ut eu felis. Etiam felis ante, tempus at turpis ac, finibus feugiat arcu. Aenean porttitor sed sapien nec feugiat. In egestas, tortor vel pulvinar hendrerit, felis massa euismod lectus, non tincidunt ipsum ex ac dolor. Praesent scelerisque posuere enim varius aliquam. Nam imperdiet massa ac vehicula luctus. Cras auctor sagittis lacus non pretium. + +In vestibulum pretium euismod. Curabitur sagittis magna turpis, sed bibendum tellus facilisis a. Proin a eros nec justo vulputate posuere vel venenatis justo. Suspendisse nisl felis, eleifend in est vel, blandit pharetra quam. Suspendisse ac velit metus. Vivamus pharetra imperdiet dolor at faucibus. Nunc ultricies id mauris pellentesque lacinia. Nam pretium velit nec augue porttitor placerat non eget mauris. Nulla dapibus aliquam mattis. Aliquam at vestibulum ex. Cras viverra, turpis in ornare venenatis, velit lectus imperdiet dolor, eget tempus odio odio sit amet lacus. Mauris pulvinar ut lorem non sagittis. Aliquam in sapien at erat dictum rutrum. + +Etiam suscipit malesuada dapibus. Maecenas pretium, tortor faucibus interdum condimentum, orci velit laoreet felis, sed feugiat purus ex nec erat. In ut elementum dolor, sit amet venenatis tortor. Vestibulum laoreet feugiat odio, at consequat sapien auctor sed. Proin a velit aliquet, pulvinar metus id, scelerisque orci. Morbi vitae elit sed nisl pharetra maximus. Praesent aliquet risus nisi, ac gravida tellus convallis a. Nunc sollicitudin urna feugiat, cursus metus malesuada, posuere ipsum. Quisque et nisl a nulla posuere imperdiet. + +Donec porttitor lorem ex, non volutpat tortor consequat in. Sed mattis, nulla a elementum placerat, velit enim malesuada tortor, ut sollicitudin ligula dolor efficitur risus. Nullam vel mollis urna. Duis vestibulum turpis neque, ut facilisis odio suscipit sed. Cras tincidunt ullamcorper nulla, vitae commodo dolor lacinia nec. Nulla aliquam posuere lectus vehicula varius. Nam ultrices eros quis vulputate blandit. Phasellus commodo mi ac aliquet bibendum. Ut quis venenatis purus, eget pellentesque diam. + +Aenean non nunc neque. Ut mattis metus massa, ac faucibus elit porttitor porttitor. Curabitur sagittis finibus enim ut varius. Donec vitae erat lacus. Vestibulum gravida gravida justo feugiat condimentum. Morbi fringilla id nisi ut sodales. Proin mollis congue hendrerit. Maecenas convallis arcu ut semper dapibus. + +Sed consectetur dignissim metus, ut laoreet ipsum pharetra vel. Sed tristique semper sapien in congue. Nullam imperdiet consequat massa non cursus. Etiam ac cursus mauris. Maecenas consequat sollicitudin dignissim. Sed lobortis, ex non luctus ornare, tortor nibh scelerisque urna, quis congue sem orci non odio. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nam porta non est ut accumsan. Nulla blandit vehicula ex, ac tristique nulla sodales vitae. Morbi est ligula, hendrerit et nisi ac, auctor congue sapien. Vestibulum luctus libero id enim pulvinar molestie. Aliquam faucibus malesuada lacus, nec luctus sem consectetur ac. Proin sit amet arcu lorem. Aenean diam libero, posuere ornare nulla quis, euismod tempor mi. Praesent sagittis urna sit amet diam ultrices, quis pharetra orci dignissim. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +Aliquam maximus diam sed purus tempus, nec sollicitudin mi laoreet. Quisque pretium vulputate magna a ultrices. Etiam felis odio, egestas at nisi eget, malesuada dignissim est. Pellentesque ac blandit sapien, et lacinia magna. Donec malesuada consectetur quam sit amet sagittis. Donec sed pulvinar dui. Nulla facilisis gravida cursus. Integer eleifend ullamcorper nunc eget blandit. Cras iaculis lacinia neque, sed placerat ex luctus a. Vestibulum molestie rutrum tellus nec pulvinar. Duis arcu neque, semper et dignissim ut, auctor eu turpis. Nullam velit est, feugiat id mollis vitae, hendrerit auctor est. + +Maecenas quis massa id sem vulputate accumsan. Sed sed faucibus erat. Duis consequat ligula magna, at faucibus sem efficitur ut. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nam molestie orci eu magna congue, ut viverra ligula iaculis. Fusce mollis scelerisque nulla, vel viverra augue dignissim non. Nullam a sem a urna pharetra pretium. In vel ligula suscipit, maximus quam quis, iaculis risus. Pellentesque tristique molestie pharetra. Proin porttitor purus vel sapien cursus, venenatis commodo lacus mollis. Duis pretium augue imperdiet venenatis dignissim. Integer est arcu, vestibulum et lacus vitae, aliquam fermentum magna. + +Pellentesque faucibus molestie eros ac molestie. Integer sed risus nunc. Donec viverra volutpat metus, at sagittis ipsum vehicula in. Quisque semper justo semper odio aliquam mollis. Nunc auctor eros viverra enim vulputate efficitur. Nulla placerat elit lectus, sit amet facilisis magna tincidunt eget. Etiam mollis ex vel felis maximus, ut auctor nunc dignissim. Nunc cursus, lacus non lobortis sagittis, eros elit bibendum ex, vitae commodo elit ligula id nibh. + +Ut iaculis ornare lacinia. Donec pharetra tellus ipsum, sollicitudin finibus justo pharetra ut. Phasellus feugiat sem eget neque congue pharetra. Maecenas cursus in velit ut hendrerit. Fusce non pellentesque ex. Aliquam erat volutpat. Vestibulum eleifend egestas arcu in varius. Aliquam egestas nisl metus, eu sodales orci porttitor fermentum. In volutpat, nulla vitae sodales laoreet, augue lectus molestie quam, nec posuere metus lectus at orci. Phasellus porttitor orci a erat sodales, nec convallis diam venenatis. Sed molestie id lorem eu ultricies. + +Fusce mattis tempus pulvinar. Sed condimentum vulputate sem, nec efficitur magna molestie in. Nulla mattis odio at nibh dignissim, in hendrerit orci sodales. Maecenas tellus tortor, vulputate at pulvinar ac, luctus quis erat. Curabitur orci ex, pharetra quis est eu, aliquam pharetra augue. Phasellus magna neque, tempus gravida interdum sit amet, eleifend non velit. Morbi fermentum congue suscipit. Etiam mattis mattis dui sed consectetur. Duis purus dolor, convallis eget odio et, iaculis vestibulum neque. Etiam sagittis eros felis, ac dapibus tortor pharetra eget. Quisque ullamcorper purus sed magna cursus pharetra. + +Nam vitae eleifend nibh, ut laoreet nisl. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin vestibulum quam mi, ac lacinia libero aliquet nec. Nunc aliquam quam velit, in consequat ex aliquet at. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed pellentesque, nisl nec pellentesque blandit, lorem turpis blandit quam, sed scelerisque sapien sem id quam. Maecenas vehicula a diam in pretium. + +In massa lectus, elementum et laoreet sed, elementum et est. Sed et eleifend mi. Pellentesque urna elit, interdum eget nibh a, scelerisque ornare leo. In lobortis vehicula lectus sit amet fringilla. In dapibus in mauris nec vehicula. Nullam massa mi, cursus id urna dignissim, venenatis mollis ligula. Curabitur fringilla nec orci at fringilla. \ No newline at end of file