Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit cba31bc1 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Add storage for the App Function Agent allowlist." into main

parents b250bf93 42144756
Loading
Loading
Loading
Loading
+97 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.appfunctions;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.WorkerThread;
import android.content.pm.SignedPackage;
import android.util.AtomicFile;
import android.util.Slog;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

/** Handles reading and writing the App Function agent allowlist to persistent storage. */
public final class AppFunctionAgentAllowlistStorage {

    private static final String TAG = "AppFunctionAgentAllowlistStorage";

    @NonNull private final AtomicFile mAtomicFile;

    /**
     * Creates an instance that manages the allowlist at the specified file path.
     *
     * @param file The file to read from and write to.
     */
    public AppFunctionAgentAllowlistStorage(@NonNull File file) {
        mAtomicFile = new AtomicFile(file);
    }

    /**
     * Reads and parses the allowlist from persistent storage.
     *
     * <p>This is a blocking operation and should be called on a worker thread.
     *
     * <p>If the file is found to be corrupt during reading or parsing, it will be deleted.
     *
     * @return A list of {@link SignedPackage}s if the file exists and is valid, or {@code null}
     */
    @WorkerThread
    @Nullable
    public List<SignedPackage> readPreviousValidAllowlist() {
        if (!mAtomicFile.exists()) {
            Slog.d(TAG, "Allowlist file does not exist.");
            return null;
        }

        try {
            byte[] data = mAtomicFile.readFully();
            String allowlistString = new String(data, StandardCharsets.UTF_8);
            return SignedPackageParser.parseList(allowlistString);
        } catch (Exception e) {
            Slog.e(TAG, "Error reading or parsing allowlist file", e);
            mAtomicFile.delete();
            return null;
        }
    }

    /**
     * Writes the given allowlist string to persistent storage atomically.
     *
     * <p>This is a blocking operation and should be called on a worker thread.
     *
     * @param allowlistString The raw string representation of the allowlist to be written.
     */
    @WorkerThread
    public void writeCurrentAllowlist(@NonNull String allowlistString) {
        FileOutputStream fos = null;
        try {
            fos = mAtomicFile.startWrite();
            fos.write(allowlistString.getBytes(StandardCharsets.UTF_8));
            mAtomicFile.finishWrite(fos);
        } catch (IOException e) {
            Slog.e(TAG, "Error writing allowlist file", e);
            if (fos != null) {
                mAtomicFile.failWrite(fos);
            }
        }
    }
}
+130 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.appfunctions

import android.content.Context
import android.content.pm.Signature
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import java.io.File
import java.nio.charset.StandardCharsets
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class AppFunctionAgentAllowlistStorageTest {

    private lateinit var context: Context
    private lateinit var testDir: File
    private lateinit var storage: AppFunctionAgentAllowlistStorage
    private lateinit var allowlistFile: File

    @Before
    fun setUp() {
        context = ApplicationProvider.getApplicationContext()
        // Use a temporary directory for testing
        testDir = File(context.cacheDir, "appfunctions_test")
        testDir.mkdirs()
        val allowlistDir = File(testDir, "system/appfunctions")
        allowlistDir.mkdirs() // Ensure the parent directory exists
        allowlistFile = File(allowlistDir, "agent_allowlist.txt")
        storage = AppFunctionAgentAllowlistStorage(allowlistFile)
    }

    @After
    fun tearDown() {
        testDir.deleteRecursively()
    }

    @Test
    fun readPreviousValidAllowlist_fileDoesNotExist_returnsNull() {
        val allowlist = storage.readPreviousValidAllowlist()
        assertThat(allowlist).isNull()
    }

    @Test
    fun writeCurrentValidAllowlist_readsBackCorrectly() {
        val allowlistString =
            "$TEST_PACKAGE_NAME_1:$TEST_CERTIFICATE_STRING_1;" +
                "$TEST_PACKAGE_NAME_2:$TEST_CERTIFICATE_STRING_2"
        storage.writeCurrentAllowlist(allowlistString)

        val readAllowlist = storage.readPreviousValidAllowlist()

        assertThat(readAllowlist).isNotNull()
        assertThat(readAllowlist).hasSize(2)
        assertThat(readAllowlist!![0].packageName).isEqualTo(TEST_PACKAGE_NAME_1)
        assertThat(readAllowlist[0].certificateDigest).isEqualTo(TEST_CERTIFICATE_DIGEST_1)
        assertThat(readAllowlist[1].packageName).isEqualTo(TEST_PACKAGE_NAME_2)
        assertThat(readAllowlist[1].certificateDigest).isEqualTo(TEST_CERTIFICATE_DIGEST_2)
    }

    @Test
    fun readPreviousValidAllowlist_invalidFileContent_returnsNullAndDeletesFile() {
        val invalidAllowlistString = "$TEST_PACKAGE_NAME_1:$INVALID_CERTIFICATE_STRING"

        storage.writeCurrentAllowlist(invalidAllowlistString)
        assertThat(allowlistFile.exists()).isTrue()

        val allowlist = storage.readPreviousValidAllowlist()

        assertThat(allowlist).isNull()
        assertThat(allowlistFile.exists()).isFalse()
    }

    @Test
    fun writeCurrentValidAllowlist_emptyString_createsEmptyFile() {
        val allowlistString = ""
        storage.writeCurrentAllowlist(allowlistString)

        val readAllowlist = storage.readPreviousValidAllowlist()

        assertThat(readAllowlist).isNotNull()
        assertThat(readAllowlist).isEmpty()
        assertThat(allowlistFile.exists()).isTrue()
        assertThat(allowlistFile.readText(StandardCharsets.UTF_8)).isEmpty()
    }

    @Test
    fun writeCurrentAllowlist_writeTwice_readsBackCorrectlyTheSecondValue() {
        val allowlistString1 = "$TEST_PACKAGE_NAME_1:$TEST_CERTIFICATE_STRING_1;"
        storage.writeCurrentAllowlist(allowlistString1)
        val allowlistString2 = "$TEST_PACKAGE_NAME_2:$TEST_CERTIFICATE_STRING_2"
        storage.writeCurrentAllowlist(allowlistString2)

        val readAllowlist = storage.readPreviousValidAllowlist()

        assertThat(readAllowlist).isNotNull()
        assertThat(readAllowlist).hasSize(1)
        assertThat(readAllowlist!![0].packageName).isEqualTo(TEST_PACKAGE_NAME_2)
        assertThat(readAllowlist[0].certificateDigest).isEqualTo(TEST_CERTIFICATE_DIGEST_2)
    }

    private companion object {
        const val TEST_PACKAGE_NAME_1 = "com.example.test1"
        const val TEST_PACKAGE_NAME_2 = "com.example.test2"
        const val TEST_CERTIFICATE_STRING_1 = "abcdef0123456789"
        val TEST_CERTIFICATE_DIGEST_1 = Signature(TEST_CERTIFICATE_STRING_1).toByteArray()
        const val TEST_CERTIFICATE_STRING_2 = "9876543210fedcba"
        val TEST_CERTIFICATE_DIGEST_2 = Signature(TEST_CERTIFICATE_STRING_2).toByteArray()

        const val INVALID_CERTIFICATE_STRING = "invalid_certificate_string"
    }
}