Import ProtoStore

Bug: 111386661
Test: make RunBackupEncryptionRoboTests
Change-Id: I9cbaf2c1f1e933b08ac578e4243e8555e552ef1d
diff --git a/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ProtoStore.java b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ProtoStore.java
new file mode 100644
index 0000000..3ba5f2b
--- /dev/null
+++ b/packages/BackupEncryption/src/com/android/server/backup/encryption/chunking/ProtoStore.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * 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.android.server.backup.encryption.chunking;
+
+import static com.android.internal.util.Preconditions.checkNotNull;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AtomicFile;
+import android.util.Slog;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto;
+import com.android.server.backup.encryption.protos.nano.KeyValueListingProto;
+
+import com.google.protobuf.nano.MessageNano;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Optional;
+
+/**
+ * Stores a nano proto for each package, persisting the proto to disk.
+ *
+ * <p>This is used to store {@link ChunksMetadataProto.ChunkListing}.
+ *
+ * @param <T> the type of nano proto to store.
+ */
+public class ProtoStore<T extends MessageNano> {
+    private static final String CHUNK_LISTING_FOLDER = "backup_chunk_listings";
+    private static final String KEY_VALUE_LISTING_FOLDER = "backup_kv_listings";
+
+    private static final String TAG = "BupEncProtoStore";
+
+    private final File mStoreFolder;
+    private final Class<T> mClazz;
+
+    /** Creates a new instance which stores chunk listings at the default location. */
+    public static ProtoStore<ChunksMetadataProto.ChunkListing> createChunkListingStore(
+            Context context) throws IOException {
+        return new ProtoStore<>(
+                ChunksMetadataProto.ChunkListing.class,
+                new File(context.getFilesDir().getAbsoluteFile(), CHUNK_LISTING_FOLDER));
+    }
+
+    /** Creates a new instance which stores key value listings in the default location. */
+    public static ProtoStore<KeyValueListingProto.KeyValueListing> createKeyValueListingStore(
+            Context context) throws IOException {
+        return new ProtoStore<>(
+                KeyValueListingProto.KeyValueListing.class,
+                new File(context.getFilesDir().getAbsoluteFile(), KEY_VALUE_LISTING_FOLDER));
+    }
+
+    /**
+     * Creates a new instance which stores protos in the given folder.
+     *
+     * @param storeFolder The location where the serialized form is stored.
+     */
+    @VisibleForTesting
+    ProtoStore(Class<T> clazz, File storeFolder) throws IOException {
+        mClazz = checkNotNull(clazz);
+        mStoreFolder = ensureDirectoryExistsOrThrow(storeFolder);
+    }
+
+    private static File ensureDirectoryExistsOrThrow(File directory) throws IOException {
+        if (directory.exists() && !directory.isDirectory()) {
+            throw new IOException("Store folder already exists, but isn't a directory.");
+        }
+
+        if (!directory.exists() && !directory.mkdir()) {
+            throw new IOException("Unable to create store folder.");
+        }
+
+        return directory;
+    }
+
+    /**
+     * Returns the chunk listing for the given package, or {@link Optional#empty()} if no listing
+     * exists.
+     */
+    public Optional<T> loadProto(String packageName)
+            throws IOException, IllegalAccessException, InstantiationException,
+            NoSuchMethodException, InvocationTargetException {
+        File file = getFileForPackage(packageName);
+
+        if (!file.exists()) {
+            Slog.d(
+                    TAG,
+                    "No chunk listing existed for " + packageName + ", returning empty listing.");
+            return Optional.empty();
+        }
+
+        AtomicFile protoStore = new AtomicFile(file);
+        byte[] data = protoStore.readFully();
+
+        Constructor<T> constructor = mClazz.getDeclaredConstructor();
+        T proto = constructor.newInstance();
+        MessageNano.mergeFrom(proto, data);
+        return Optional.of(proto);
+    }
+
+    /** Saves a proto to disk, associating it with the given package. */
+    public void saveProto(String packageName, T proto) throws IOException {
+        checkNotNull(proto);
+        File file = getFileForPackage(packageName);
+
+        try (FileOutputStream os = new FileOutputStream(file)) {
+            os.write(MessageNano.toByteArray(proto));
+        } catch (IOException e) {
+            Slog.e(
+                    TAG,
+                    "Exception occurred when saving the listing for "
+                            + packageName
+                            + ", deleting saved listing.",
+                    e);
+
+            // If a problem occurred when writing the listing then it might be corrupt, so delete
+            // it.
+            file.delete();
+
+            throw e;
+        }
+    }
+
+    /** Deletes the proto for the given package, or does nothing if the package has no proto. */
+    public void deleteProto(String packageName) {
+        File file = getFileForPackage(packageName);
+        file.delete();
+    }
+
+    /** Deletes every proto of this type, for all package names. */
+    public void deleteAllProtos() {
+        File[] files = mStoreFolder.listFiles();
+
+        // We ensure that the storeFolder exists in the constructor, but check just in case it has
+        // mysteriously disappeared.
+        if (files == null) {
+            return;
+        }
+
+        for (File file : files) {
+            file.delete();
+        }
+    }
+
+    private File getFileForPackage(String packageName) {
+        checkPackageName(packageName);
+        return new File(mStoreFolder, packageName);
+    }
+
+    private static void checkPackageName(String packageName) {
+        if (TextUtils.isEmpty(packageName) || packageName.contains("/")) {
+            throw new IllegalArgumentException(
+                    "Package name must not contain '/' or be empty: " + packageName);
+        }
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ProtoStoreTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ProtoStoreTest.java
new file mode 100644
index 0000000..d73c8e4
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ProtoStoreTest.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * 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.android.server.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.testng.Assert.assertThrows;
+
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+import com.android.server.backup.encryption.protos.nano.ChunksMetadataProto;
+import com.android.server.backup.encryption.protos.nano.KeyValueListingProto;
+
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Optional;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ProtoStoreTest {
+    private static final String TEST_KEY_1 = "test_key_1";
+    private static final ChunkHash TEST_HASH_1 =
+            new ChunkHash(Arrays.copyOf(new byte[] {1}, EncryptedChunk.KEY_LENGTH_BYTES));
+    private static final ChunkHash TEST_HASH_2 =
+            new ChunkHash(Arrays.copyOf(new byte[] {2}, EncryptedChunk.KEY_LENGTH_BYTES));
+    private static final int TEST_LENGTH_1 = 10;
+    private static final int TEST_LENGTH_2 = 18;
+
+    private static final String TEST_PACKAGE_1 = "com.example.test1";
+    private static final String TEST_PACKAGE_2 = "com.example.test2";
+
+    @Rule public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
+    private File mStoreFolder;
+    private ProtoStore<ChunksMetadataProto.ChunkListing> mProtoStore;
+
+    @Before
+    public void setUp() throws Exception {
+        mStoreFolder = mTemporaryFolder.newFolder();
+        mProtoStore = new ProtoStore<>(ChunksMetadataProto.ChunkListing.class, mStoreFolder);
+    }
+
+    @Test
+    public void differentStoreTypes_operateSimultaneouslyWithoutInterfering() throws Exception {
+        ChunksMetadataProto.ChunkListing chunkListing =
+                createChunkListing(ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1));
+        KeyValueListingProto.KeyValueListing keyValueListing =
+                new KeyValueListingProto.KeyValueListing();
+        keyValueListing.entries = new KeyValueListingProto.KeyValueEntry[1];
+        keyValueListing.entries[0] = new KeyValueListingProto.KeyValueEntry();
+        keyValueListing.entries[0].key = TEST_KEY_1;
+        keyValueListing.entries[0].hash = TEST_HASH_1.getHash();
+
+        Context application = ApplicationProvider.getApplicationContext();
+        ProtoStore<ChunksMetadataProto.ChunkListing> chunkListingStore =
+                ProtoStore.createChunkListingStore(application);
+        ProtoStore<KeyValueListingProto.KeyValueListing> keyValueListingStore =
+                ProtoStore.createKeyValueListingStore(application);
+
+        chunkListingStore.saveProto(TEST_PACKAGE_1, chunkListing);
+        keyValueListingStore.saveProto(TEST_PACKAGE_1, keyValueListing);
+
+        ChunksMetadataProto.ChunkListing actualChunkListing =
+                chunkListingStore.loadProto(TEST_PACKAGE_1).get();
+        KeyValueListingProto.KeyValueListing actualKeyValueListing =
+                keyValueListingStore.loadProto(TEST_PACKAGE_1).get();
+        assertListingsEqual(actualChunkListing, chunkListing);
+        assertThat(actualKeyValueListing.entries.length).isEqualTo(1);
+        assertThat(actualKeyValueListing.entries[0].key).isEqualTo(TEST_KEY_1);
+        assertThat(actualKeyValueListing.entries[0].hash).isEqualTo(TEST_HASH_1.getHash());
+    }
+
+    @Test
+    public void construct_storeLocationIsFile_throws() throws Exception {
+        assertThrows(
+                IOException.class,
+                () ->
+                        new ProtoStore<>(
+                                ChunksMetadataProto.ChunkListing.class,
+                                mTemporaryFolder.newFile()));
+    }
+
+    @Test
+    public void loadChunkListing_noListingExists_returnsEmptyListing() throws Exception {
+        Optional<ChunksMetadataProto.ChunkListing> chunkListing =
+                mProtoStore.loadProto(TEST_PACKAGE_1);
+        assertThat(chunkListing.isPresent()).isFalse();
+    }
+
+    @Test
+    public void loadChunkListing_listingExists_returnsExistingListing() throws Exception {
+        ChunksMetadataProto.ChunkListing expected =
+                createChunkListing(
+                        ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1, TEST_HASH_2, TEST_LENGTH_2));
+        mProtoStore.saveProto(TEST_PACKAGE_1, expected);
+
+        ChunksMetadataProto.ChunkListing result = mProtoStore.loadProto(TEST_PACKAGE_1).get();
+
+        assertListingsEqual(result, expected);
+    }
+
+    @Test
+    public void loadProto_emptyPackageName_throwsException() throws Exception {
+        assertThrows(IllegalArgumentException.class, () -> mProtoStore.loadProto(""));
+    }
+
+    @Test
+    public void loadProto_nullPackageName_throwsException() throws Exception {
+        assertThrows(IllegalArgumentException.class, () -> mProtoStore.loadProto(null));
+    }
+
+    @Test
+    public void loadProto_packageNameContainsSlash_throwsException() throws Exception {
+        assertThrows(
+                IllegalArgumentException.class, () -> mProtoStore.loadProto(TEST_PACKAGE_1 + "/"));
+    }
+
+    @Test
+    public void saveProto_persistsToNewInstance() throws Exception {
+        ChunksMetadataProto.ChunkListing expected =
+                createChunkListing(
+                        ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1, TEST_HASH_2, TEST_LENGTH_2));
+        mProtoStore.saveProto(TEST_PACKAGE_1, expected);
+        mProtoStore = new ProtoStore<>(ChunksMetadataProto.ChunkListing.class, mStoreFolder);
+
+        ChunksMetadataProto.ChunkListing result = mProtoStore.loadProto(TEST_PACKAGE_1).get();
+
+        assertListingsEqual(result, expected);
+    }
+
+    @Test
+    public void saveProto_emptyPackageName_throwsException() throws Exception {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mProtoStore.saveProto("", new ChunksMetadataProto.ChunkListing()));
+    }
+
+    @Test
+    public void saveProto_nullPackageName_throwsException() throws Exception {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mProtoStore.saveProto(null, new ChunksMetadataProto.ChunkListing()));
+    }
+
+    @Test
+    public void saveProto_packageNameContainsSlash_throwsException() throws Exception {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        mProtoStore.saveProto(
+                                TEST_PACKAGE_1 + "/", new ChunksMetadataProto.ChunkListing()));
+    }
+
+    @Test
+    public void saveProto_nullListing_throwsException() throws Exception {
+        assertThrows(NullPointerException.class, () -> mProtoStore.saveProto(TEST_PACKAGE_1, null));
+    }
+
+    @Test
+    public void deleteProto_noListingExists_doesNothing() throws Exception {
+        ChunksMetadataProto.ChunkListing listing =
+                createChunkListing(ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1));
+        mProtoStore.saveProto(TEST_PACKAGE_1, listing);
+
+        mProtoStore.deleteProto(TEST_PACKAGE_2);
+
+        assertThat(mProtoStore.loadProto(TEST_PACKAGE_1).get().chunks.length).isEqualTo(1);
+    }
+
+    @Test
+    public void deleteProto_listingExists_deletesListing() throws Exception {
+        ChunksMetadataProto.ChunkListing listing =
+                createChunkListing(ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1));
+        mProtoStore.saveProto(TEST_PACKAGE_1, listing);
+
+        mProtoStore.deleteProto(TEST_PACKAGE_1);
+
+        assertThat(mProtoStore.loadProto(TEST_PACKAGE_1).isPresent()).isFalse();
+    }
+
+    @Test
+    public void deleteAllProtos_deletesAllProtos() throws Exception {
+        ChunksMetadataProto.ChunkListing listing1 =
+                createChunkListing(ImmutableMap.of(TEST_HASH_1, TEST_LENGTH_1));
+        ChunksMetadataProto.ChunkListing listing2 =
+                createChunkListing(ImmutableMap.of(TEST_HASH_2, TEST_LENGTH_2));
+        mProtoStore.saveProto(TEST_PACKAGE_1, listing1);
+        mProtoStore.saveProto(TEST_PACKAGE_2, listing2);
+
+        mProtoStore.deleteAllProtos();
+
+        assertThat(mProtoStore.loadProto(TEST_PACKAGE_1).isPresent()).isFalse();
+        assertThat(mProtoStore.loadProto(TEST_PACKAGE_2).isPresent()).isFalse();
+    }
+
+    @Test
+    public void deleteAllProtos_folderDeleted_doesNotCrash() throws Exception {
+        mStoreFolder.delete();
+
+        mProtoStore.deleteAllProtos();
+    }
+
+    private static ChunksMetadataProto.ChunkListing createChunkListing(
+            ImmutableMap<ChunkHash, Integer> chunks) {
+        ChunksMetadataProto.ChunkListing listing = new ChunksMetadataProto.ChunkListing();
+        listing.cipherType = ChunksMetadataProto.AES_256_GCM;
+        listing.chunkOrderingType = ChunksMetadataProto.CHUNK_ORDERING_TYPE_UNSPECIFIED;
+
+        List<ChunksMetadataProto.Chunk> chunkProtos = new ArrayList<>();
+        for (Entry<ChunkHash, Integer> entry : chunks.entrySet()) {
+            ChunksMetadataProto.Chunk chunk = new ChunksMetadataProto.Chunk();
+            chunk.hash = entry.getKey().getHash();
+            chunk.length = entry.getValue();
+            chunkProtos.add(chunk);
+        }
+        listing.chunks = chunkProtos.toArray(new ChunksMetadataProto.Chunk[0]);
+        return listing;
+    }
+
+    private void assertListingsEqual(
+            ChunksMetadataProto.ChunkListing result, ChunksMetadataProto.ChunkListing expected) {
+        assertThat(result.chunks.length).isEqualTo(expected.chunks.length);
+        for (int i = 0; i < result.chunks.length; i++) {
+            assertWithMessage("Chunk " + i)
+                    .that(result.chunks[i].length)
+                    .isEqualTo(expected.chunks[i].length);
+            assertWithMessage("Chunk " + i)
+                    .that(result.chunks[i].hash)
+                    .isEqualTo(expected.chunks[i].hash);
+        }
+    }
+}