1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.file.openers; 17 18 import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeContentThatExceedsOsBufferSize; 19 import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileFromSource; 20 import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink; 21 import static com.google.common.truth.Truth.assertThat; 22 import static org.junit.Assert.assertThrows; 23 24 import android.content.Context; 25 import android.net.Uri; 26 import android.os.Process; 27 import android.system.Os; 28 import android.system.OsConstants; 29 import androidx.test.core.app.ApplicationProvider; 30 import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage; 31 import com.google.android.libraries.mobiledatadownload.file.backends.FileUri; 32 import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend; 33 import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible; 34 import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream; 35 import com.google.android.libraries.mobiledatadownload.file.common.testing.AlwaysThrowsTransform; 36 import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker; 37 import com.google.android.libraries.mobiledatadownload.file.spi.Backend; 38 import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform; 39 import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos; 40 import com.google.common.collect.ImmutableList; 41 import java.io.File; 42 import java.io.FileInputStream; 43 import java.io.FileOutputStream; 44 import java.io.IOException; 45 import java.io.OutputStream; 46 import java.util.concurrent.ExecutorService; 47 import java.util.concurrent.Executors; 48 import org.junit.Before; 49 import org.junit.Rule; 50 import org.junit.Test; 51 import org.junit.rules.TemporaryFolder; 52 import org.junit.runner.RunWith; 53 import org.junit.runners.JUnit4; 54 55 @RunWith(JUnit4.class) 56 public final class WriteFileOpenerAndroidTest { 57 58 private final String bigContent = makeContentThatExceedsOsBufferSize(); 59 private final String smallContent = "content"; 60 private SynchronousFileStorage storage; 61 private ExecutorService executor = Executors.newCachedThreadPool(); 62 private final Context context = ApplicationProvider.getApplicationContext(); 63 64 @Rule public TemporaryFolder tmpFolder = new TemporaryFolder(); 65 @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker(); 66 67 @Before setUpStorage()68 public void setUpStorage() throws Exception { 69 storage = 70 new SynchronousFileStorage( 71 ImmutableList.of(new JavaFileBackend()), 72 ImmutableList.of(new CompressTransform(), new AlwaysThrowsTransform())); 73 } 74 75 @Test compressAndWriteToPipe()76 public void compressAndWriteToPipe() throws Exception { 77 Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 78 File pipedFile; 79 try (WriteFileOpener.FileCloser piped = 80 storage.open( 81 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) { 82 pipedFile = piped.file(); 83 assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo"); 84 writeFileToSink(new FileOutputStream(pipedFile), bigContent); 85 } 86 assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create()))) 87 .isEqualTo(bigContent); 88 assertThat(pipedFile.exists()).isFalse(); 89 } 90 91 @Test compressButDontWriteToPipe_shouldNotLeak()92 public void compressButDontWriteToPipe_shouldNotLeak() throws Exception { 93 Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 94 File pipedFile; 95 try (WriteFileOpener.FileCloser piped = 96 storage.open( 97 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) { 98 pipedFile = piped.file(); 99 assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo"); 100 } 101 assertThat(pipedFile.exists()).isFalse(); 102 } 103 104 @Test staleFifo_isDeletedAndReplaced()105 public void staleFifo_isDeletedAndReplaced() throws Exception { 106 Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 107 108 String staleFifoName = ".mobstore-WriteFileOpener-" + Process.myPid() + "-0.fifo"; 109 File staleFifo = new File(context.getCacheDir(), staleFifoName); 110 Os.mkfifo(staleFifo.getAbsolutePath(), OsConstants.S_IRUSR | OsConstants.S_IWUSR); 111 112 File pipedFile; 113 try (WriteFileOpener.FileCloser piped = 114 storage.open( 115 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) { 116 pipedFile = piped.file(); 117 assertThat(pipedFile).isEqualTo(staleFifo); 118 writeFileToSink(new FileOutputStream(pipedFile), bigContent); 119 } 120 121 assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create()))) 122 .isEqualTo(bigContent); 123 assertThat(staleFifo.exists()).isFalse(); 124 } 125 126 @Test multipleStreams_shouldCreateMultipleFifos()127 public void multipleStreams_shouldCreateMultipleFifos() throws Exception { 128 Uri uri0 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 129 Uri uri1 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 130 Uri uri2 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 131 132 WriteFileOpener.FileCloser piped0 = 133 storage.open( 134 uri0, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)); 135 WriteFileOpener.FileCloser piped1 = 136 storage.open( 137 uri1, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)); 138 WriteFileOpener.FileCloser piped2 = 139 storage.open( 140 uri2, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)); 141 142 assertThat(piped0.file().getAbsolutePath()).endsWith("-0.fifo"); 143 assertThat(piped1.file().getAbsolutePath()).endsWith("-1.fifo"); 144 assertThat(piped2.file().getAbsolutePath()).endsWith("-2.fifo"); 145 146 writeFileToSink(new FileOutputStream(piped0.file()), bigContent + "0"); 147 writeFileToSink(new FileOutputStream(piped1.file()), bigContent + "1"); 148 writeFileToSink(new FileOutputStream(piped2.file()), bigContent + "2"); 149 150 piped0.close(); 151 piped1.close(); 152 piped2.close(); 153 154 assertThat(readFileFromSource(storage.open(uri0, ReadStreamOpener.create()))) 155 .isEqualTo(bigContent + "0"); 156 assertThat(readFileFromSource(storage.open(uri1, ReadStreamOpener.create()))) 157 .isEqualTo(bigContent + "1"); 158 assertThat(readFileFromSource(storage.open(uri2, ReadStreamOpener.create()))) 159 .isEqualTo(bigContent + "2"); 160 161 assertThat(piped0.file().exists()).isFalse(); 162 assertThat(piped1.file().exists()).isFalse(); 163 assertThat(piped2.file().exists()).isFalse(); 164 } 165 166 @Test compressAndWriteToPipeWithoutExecutor_shouldFail()167 public void compressAndWriteToPipeWithoutExecutor_shouldFail() throws Exception { 168 Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build(); 169 assertThrows(IOException.class, () -> storage.open(uri, WriteFileOpener.create())); 170 } 171 172 @Test writeBigContentWithException_shouldThrowEPipeAndPropagate()173 public void writeBigContentWithException_shouldThrowEPipeAndPropagate() throws Exception { 174 Uri uri = 175 uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build(); 176 WriteFileOpener.FileCloser piped = 177 storage.open( 178 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)); 179 // Throws EPIPE while writing. 180 assertThrows( 181 IOException.class, () -> writeFileToSink(new FileOutputStream(piped.file()), bigContent)); 182 // Throws underlying exception when closing. 183 assertThrows(IOException.class, () -> piped.close()); 184 assertThat(piped.file().exists()).isFalse(); 185 } 186 187 @Test writeSmallContentWithException_shouldPropagate()188 public void writeSmallContentWithException_shouldPropagate() throws Exception { 189 Uri uri = 190 uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build(); 191 WriteFileOpener.FileCloser piped = 192 storage.open( 193 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)); 194 // Small content is buffered and pump failure is is not visible. 195 writeFileToSink(new FileOutputStream(piped.file()), smallContent); 196 // Throws underlying exception when closing. 197 assertThrows(IOException.class, () -> piped.close()); 198 assertThat(piped.file().exists()).isFalse(); 199 } 200 201 @Test writeToPlainFile()202 public void writeToPlainFile() throws Exception { 203 Uri uri = uriToNewTempFile().build(); // No transforms. 204 try (WriteFileOpener.FileCloser direct = 205 storage.open( 206 uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) { 207 assertThat(direct.file().getAbsolutePath()).startsWith(tmpFolder.getRoot().toString()); 208 writeFileToSink(new FileOutputStream(direct.file()), bigContent); 209 assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create()))) 210 .isEqualTo(bigContent); 211 } 212 } 213 214 @Test writeToPlainFile_shouldNotPrematurelyCloseStream()215 public void writeToPlainFile_shouldNotPrematurelyCloseStream() throws Exception { 216 // No transforms, write to stub test backend 217 storage = new SynchronousFileStorage(ImmutableList.of(new BufferingBackend())); 218 File file = tmpFolder.newFile(); 219 Uri uri = Uri.parse("buffer:///" + file.getAbsolutePath()); 220 221 try (WriteFileOpener.FileCloser direct = storage.open(uri, WriteFileOpener.create())) { 222 writeFileToSink(new FileOutputStream(direct.file()), bigContent); 223 } 224 assertThat(readFileFromSource(new FileInputStream(file))).isEqualTo(bigContent); 225 } 226 uriToNewTempFile()227 private FileUri.Builder uriToNewTempFile() throws Exception { 228 return FileUri.builder().fromFile(tmpFolder.newFile()); 229 } 230 231 /** A backend that uses temporary files to buffer IO operations. */ 232 private static class BufferingBackend implements Backend { 233 @Override name()234 public String name() { 235 return "buffer"; 236 } 237 238 @Override openForWrite(Uri uri)239 public OutputStream openForWrite(Uri uri) throws IOException { 240 File tempFile = new File(uri.getPath() + ".tmp"); 241 File finalFile = new File(uri.getPath()); 242 return new BufferingOutputStream(new FileOutputStream(tempFile), tempFile, finalFile); 243 } 244 245 private static class BufferingOutputStream extends ForwardingOutputStream 246 implements FileConvertible { 247 private final File tempFile; 248 private final File finalFile; 249 BufferingOutputStream(OutputStream stream, File tempFile, File finalFile)250 BufferingOutputStream(OutputStream stream, File tempFile, File finalFile) { 251 super(stream); 252 this.tempFile = tempFile; 253 this.finalFile = finalFile; 254 } 255 256 @Override toFile()257 public File toFile() { 258 return tempFile; 259 } 260 261 @Override close()262 public void close() throws IOException { 263 out.close(); 264 tempFile.renameTo(finalFile); 265 } 266 } 267 } 268 } 269