blob: d22d692c86352a7a46a8e24dfb704c74e93f4562 [file] [log] [blame]
/*
* Copyright 2022 Google LLC
*
* 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.google.android.libraries.mobiledatadownload.file.openers;
import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeContentThatExceedsOsBufferSize;
import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileFromSource;
import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.content.Context;
import android.net.Uri;
import android.os.Process;
import android.system.Os;
import android.system.OsConstants;
import androidx.test.core.app.ApplicationProvider;
import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
import com.google.android.libraries.mobiledatadownload.file.common.testing.AlwaysThrowsTransform;
import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
import com.google.common.collect.ImmutableList;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public final class WriteFileOpenerAndroidTest {
private final String bigContent = makeContentThatExceedsOsBufferSize();
private final String smallContent = "content";
private SynchronousFileStorage storage;
private ExecutorService executor = Executors.newCachedThreadPool();
private final Context context = ApplicationProvider.getApplicationContext();
@Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
@Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
@Before
public void setUpStorage() throws Exception {
storage =
new SynchronousFileStorage(
ImmutableList.of(new JavaFileBackend()),
ImmutableList.of(new CompressTransform(), new AlwaysThrowsTransform()));
}
@Test
public void compressAndWriteToPipe() throws Exception {
Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
File pipedFile;
try (WriteFileOpener.FileCloser piped =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
pipedFile = piped.file();
assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo");
writeFileToSink(new FileOutputStream(pipedFile), bigContent);
}
assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
.isEqualTo(bigContent);
assertThat(pipedFile.exists()).isFalse();
}
@Test
public void compressButDontWriteToPipe_shouldNotLeak() throws Exception {
Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
File pipedFile;
try (WriteFileOpener.FileCloser piped =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
pipedFile = piped.file();
assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo");
}
assertThat(pipedFile.exists()).isFalse();
}
@Test
public void staleFifo_isDeletedAndReplaced() throws Exception {
Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
String staleFifoName = ".mobstore-WriteFileOpener-" + Process.myPid() + "-0.fifo";
File staleFifo = new File(context.getCacheDir(), staleFifoName);
Os.mkfifo(staleFifo.getAbsolutePath(), OsConstants.S_IRUSR | OsConstants.S_IWUSR);
File pipedFile;
try (WriteFileOpener.FileCloser piped =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
pipedFile = piped.file();
assertThat(pipedFile).isEqualTo(staleFifo);
writeFileToSink(new FileOutputStream(pipedFile), bigContent);
}
assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
.isEqualTo(bigContent);
assertThat(staleFifo.exists()).isFalse();
}
@Test
public void multipleStreams_shouldCreateMultipleFifos() throws Exception {
Uri uri0 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
Uri uri1 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
Uri uri2 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
WriteFileOpener.FileCloser piped0 =
storage.open(
uri0, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
WriteFileOpener.FileCloser piped1 =
storage.open(
uri1, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
WriteFileOpener.FileCloser piped2 =
storage.open(
uri2, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
assertThat(piped0.file().getAbsolutePath()).endsWith("-0.fifo");
assertThat(piped1.file().getAbsolutePath()).endsWith("-1.fifo");
assertThat(piped2.file().getAbsolutePath()).endsWith("-2.fifo");
writeFileToSink(new FileOutputStream(piped0.file()), bigContent + "0");
writeFileToSink(new FileOutputStream(piped1.file()), bigContent + "1");
writeFileToSink(new FileOutputStream(piped2.file()), bigContent + "2");
piped0.close();
piped1.close();
piped2.close();
assertThat(readFileFromSource(storage.open(uri0, ReadStreamOpener.create())))
.isEqualTo(bigContent + "0");
assertThat(readFileFromSource(storage.open(uri1, ReadStreamOpener.create())))
.isEqualTo(bigContent + "1");
assertThat(readFileFromSource(storage.open(uri2, ReadStreamOpener.create())))
.isEqualTo(bigContent + "2");
assertThat(piped0.file().exists()).isFalse();
assertThat(piped1.file().exists()).isFalse();
assertThat(piped2.file().exists()).isFalse();
}
@Test
public void compressAndWriteToPipeWithoutExecutor_shouldFail() throws Exception {
Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
assertThrows(IOException.class, () -> storage.open(uri, WriteFileOpener.create()));
}
@Test
public void writeBigContentWithException_shouldThrowEPipeAndPropagate() throws Exception {
Uri uri =
uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build();
WriteFileOpener.FileCloser piped =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
// Throws EPIPE while writing.
assertThrows(
IOException.class, () -> writeFileToSink(new FileOutputStream(piped.file()), bigContent));
// Throws underlying exception when closing.
assertThrows(IOException.class, () -> piped.close());
assertThat(piped.file().exists()).isFalse();
}
@Test
public void writeSmallContentWithException_shouldPropagate() throws Exception {
Uri uri =
uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build();
WriteFileOpener.FileCloser piped =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
// Small content is buffered and pump failure is is not visible.
writeFileToSink(new FileOutputStream(piped.file()), smallContent);
// Throws underlying exception when closing.
assertThrows(IOException.class, () -> piped.close());
assertThat(piped.file().exists()).isFalse();
}
@Test
public void writeToPlainFile() throws Exception {
Uri uri = uriToNewTempFile().build(); // No transforms.
try (WriteFileOpener.FileCloser direct =
storage.open(
uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
assertThat(direct.file().getAbsolutePath()).startsWith(tmpFolder.getRoot().toString());
writeFileToSink(new FileOutputStream(direct.file()), bigContent);
assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
.isEqualTo(bigContent);
}
}
@Test
public void writeToPlainFile_shouldNotPrematurelyCloseStream() throws Exception {
// No transforms, write to stub test backend
storage = new SynchronousFileStorage(ImmutableList.of(new BufferingBackend()));
File file = tmpFolder.newFile();
Uri uri = Uri.parse("buffer:///" + file.getAbsolutePath());
try (WriteFileOpener.FileCloser direct = storage.open(uri, WriteFileOpener.create())) {
writeFileToSink(new FileOutputStream(direct.file()), bigContent);
}
assertThat(readFileFromSource(new FileInputStream(file))).isEqualTo(bigContent);
}
private FileUri.Builder uriToNewTempFile() throws Exception {
return FileUri.builder().fromFile(tmpFolder.newFile());
}
/** A backend that uses temporary files to buffer IO operations. */
private static class BufferingBackend implements Backend {
@Override
public String name() {
return "buffer";
}
@Override
public OutputStream openForWrite(Uri uri) throws IOException {
File tempFile = new File(uri.getPath() + ".tmp");
File finalFile = new File(uri.getPath());
return new BufferingOutputStream(new FileOutputStream(tempFile), tempFile, finalFile);
}
private static class BufferingOutputStream extends ForwardingOutputStream
implements FileConvertible {
private final File tempFile;
private final File finalFile;
BufferingOutputStream(OutputStream stream, File tempFile, File finalFile) {
super(stream);
this.tempFile = tempFile;
this.finalFile = finalFile;
}
@Override
public File toFile() {
return tempFile;
}
@Override
public void close() throws IOException {
out.close();
tempFile.renameTo(finalFile);
}
}
}
}