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

Commit 24fe7026 authored by Joël Stemmer's avatar Joël Stemmer
Browse files

Create reader and writer of the cross-platform manifest file

The cross-platform manifest serves a similar purpose as the full backup
manifest (see the AppMetadataBackupWriter class). It will store some
additional metadata in the full backup TAR file that is meant to be used
for validation. On export, the target platform validates that the
specified bundle id and target id matches an installed app. On import,
we will validate that the data received matches the installed app and
its signature.

Since the metadata stored in the manifest is different than the existing
manifest we will store it in a separate file that's only present when
the transport indicates a cross-platform transfer is taking place. The
code writing the cross-platform manifest on backup and validating its
contents on restore will be added in the follow-up changes.

Bug: 432673356
Test: atest CrossPlatformManifestTest.java
Test: atest CrossPlatformManifestParserTest.java
Flag: com.android.server.backup.enable_cross_platform_transfer
Change-Id: I02019edcbebf49814f810e051211fe98fd47abb6
parent 0142fd6f
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -189,6 +189,14 @@ public class UserBackupManagerService {
    public static final String BACKUP_MANIFEST_FILENAME = "_manifest";
    public static final int BACKUP_MANIFEST_VERSION = 1;

    // Name and current contents version of the cross-platform manifest file
    //
    // Manifest version history:
    //
    // 1 : initial release
    public static final String CROSS_PLATFORM_MANIFEST_FILENAME = "_cross_platform_manifest";
    public static final int CROSS_PLATFORM_MANIFEST_VERSION = 1;

    // External archive format version history:
    //
    // 1 : initial release
+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.backup.crossplatform;

import static com.android.server.backup.UserBackupManagerService.CROSS_PLATFORM_MANIFEST_VERSION;

import android.app.backup.FullBackup.BackupScheme.PlatformSpecificParams;
import android.content.pm.PackageInfo;
import android.util.StringBuilderPrinter;

import com.android.server.backup.BackupUtils;

import java.io.IOException;
import java.util.HexFormat;
import java.util.List;

/**
 * The cross-platform manifest contains metadata that is used during the transfer of data to and
 * from a non-Android platform.
 *
 * @hide
 */
public class CrossPlatformManifest {
    private final String mPackageName;
    private final String mPlatform;
    private final List<PlatformSpecificParams> mPlatformSpecificParams;
    private final List<byte[]> mSignatureHashes;

    /**
     * Create a new cross-platform manifest for the given package, platform and platform specific
     * parameters.
     */
    public static CrossPlatformManifest create(
            PackageInfo packageInfo,
            String platform,
            List<PlatformSpecificParams> platformSpecificParams) {
        return new CrossPlatformManifest(
                packageInfo.packageName,
                platform,
                platformSpecificParams,
                BackupUtils.hashSignatureArray(packageInfo.signingInfo.getApkContentsSigners()));
    }

    CrossPlatformManifest(
            String packageName,
            String platform,
            List<PlatformSpecificParams> platformSpecificParams,
            List<byte[]> signatureHashes) {
        mPackageName = packageName;
        mPlatform = platform;
        mPlatformSpecificParams = platformSpecificParams;
        mSignatureHashes = signatureHashes;
    }

    public String getPackageName() {
        return mPackageName;
    }

    public String getPlatform() {
        return mPlatform;
    }

    public List<PlatformSpecificParams> getPlatformSpecificParams() {
        return mPlatformSpecificParams;
    }

    public List<byte[]> getSignatureHashes() {
        return mSignatureHashes;
    }

    /**
     * Creates the app's manifest as a byte array. All data are strings ending in LF.
     *
     * <p>The manifest format is:
     *
     * <pre>
     *     CROSS_PLATFORM_MANIFEST_VERSION
     *     package name
     *     platform
     *     # of platform specific params
     *     N*
     *       bundle id
     *       team id
     *       content version
     *     # of signatures
     *       N* (hexadecimal representation of the signature hash)
     * </pre>
     */
    public byte[] toByteArray() throws IOException {
        StringBuilder builder = new StringBuilder(4096);
        StringBuilderPrinter printer = new StringBuilderPrinter(builder);

        printer.println(Integer.toString(CROSS_PLATFORM_MANIFEST_VERSION));
        printer.println(mPackageName);
        printer.println(mPlatform);

        printer.println(Integer.toString(mPlatformSpecificParams.size()));
        for (PlatformSpecificParams params : mPlatformSpecificParams) {
            printer.println(params.getBundleId());
            printer.println(params.getTeamId());
            printer.println(params.getContentVersion());
        }

        printer.println(Integer.toString(mSignatureHashes.size()));
        for (byte[] hash : mSignatureHashes) {
            printer.println(HexFormat.of().formatHex(hash));
        }

        return builder.toString().getBytes();
    }

    /** Parses the cross-platform manifest bytes created by {@link #toByteArray}. */
    public static CrossPlatformManifest parseFrom(byte[] manifestBytes) throws IOException {
        return CrossPlatformManifestParser.parse(manifestBytes);
    }
}
+150 −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.backup.crossplatform;

import static com.android.server.backup.UserBackupManagerService.CROSS_PLATFORM_MANIFEST_VERSION;

import android.annotation.Nullable;
import android.app.backup.FullBackup.BackupScheme.PlatformSpecificParams;
import android.text.TextUtils;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;

/**
 * Parses a cross-platform manifest file stored in a backup.
 *
 * @hide
 */
public class CrossPlatformManifestParser {
    /**
     * Takes the contents of a cross platform manifest file and returns a {@link
     * CrossPlatformManifest}.
     *
     * @throws IOException when encountering an unsupported version, incomplete manifest or any
     *     other errors during parsing.
     */
    static CrossPlatformManifest parse(byte[] manifestBytes) throws IOException {
        CrossPlatformManifestParser reader =
                new CrossPlatformManifestParser(new ByteArrayInputStream(manifestBytes));
        return reader.parse();
    }

    private final InputStream mInput;

    private CrossPlatformManifestParser(InputStream inputStream) {
        mInput = inputStream;
    }

    /**
     * Reads the app's cross-platform manifest from a byte stream.
     *
     * @see CrossPlatformManifest#toByteArray() for a description of the manifest format.
     */
    private CrossPlatformManifest parse() throws IOException {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(mInput))) {
            String version = reader.readLine();
            if (!TextUtils.equals(version, Integer.toString(CROSS_PLATFORM_MANIFEST_VERSION))) {
                throw new IOException("Unsupported cross-platform manifest version: " + version);
            }

            String packageName = reader.readLine();
            if (packageName == null) {
                throw new IOException("Incomplete cross-platform manifest: missing package");
            }

            String platform = reader.readLine();
            if (platform == null) {
                throw new IOException("Incomplete cross-platform manifest: missing platform");
            }

            Integer paramsSize = parseInt(reader.readLine());
            if (paramsSize == null) {
                throw new IOException("Incomplete cross-platform manifest: missing params");
            }

            List<PlatformSpecificParams> platformSpecificParams = Collections.emptyList();
            if (paramsSize > 0) {
                platformSpecificParams = parsePlatformSpecificParams(reader, paramsSize);
            }

            Integer hashesSize = parseInt(reader.readLine());
            if (hashesSize == null) {
                throw new IOException("Incomplete cross-platform manifest: missing signatures");
            }

            List<byte[]> signatureHashes = Collections.emptyList();
            if (hashesSize > 0) {
                signatureHashes = parseSignatureHashes(reader, hashesSize);
            }

            return new CrossPlatformManifest(
                            packageName, platform, platformSpecificParams, signatureHashes);
        }
    }

    private List<PlatformSpecificParams> parsePlatformSpecificParams(
            BufferedReader reader, int numParams) throws IOException {
        ArrayList<PlatformSpecificParams> params = new ArrayList<>(numParams);
        for (int i = 0; i < numParams; i++) {
            String bundleId = reader.readLine();
            if (bundleId == null) {
                throw new IOException(
                        "Incomplete cross-platform manifest: missing bundle id parameter");
            }
            String teamId = reader.readLine();
            if (teamId == null) {
                throw new IOException(
                        "Incomplete cross-platform manifest: missing team id parameter");
            }
            String contentVersion = reader.readLine();
            if (contentVersion == null) {
                throw new IOException(
                        "Incomplete cross-platform manifest: missing content version parameter");
            }
            params.add(new PlatformSpecificParams(bundleId, teamId, contentVersion));
        }
        return params;
    }

    private List<byte[]> parseSignatureHashes(BufferedReader reader, int numSignatureHashes)
            throws IOException {
        ArrayList<byte[]> hashes = new ArrayList<>(numSignatureHashes);
        for (int i = 0; i < numSignatureHashes; i++) {
            String hash = reader.readLine();
            if (hash == null) {
                throw new IOException("Incomplete cross-platform manifest: missing signature");
            }
            hashes.add(HexFormat.of().parseHex(hash));
        }
        return hashes;
    }

    private static Integer parseInt(@Nullable String number) {
        if (number == null) {
            return null;
        }
        return Integer.parseInt(number);
    }
}
+269 −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.backup.crossplatform;

import static com.google.common.truth.Truth.assertThat;

import static org.junit.Assert.assertThrows;

import android.app.backup.FullBackup.BackupScheme.PlatformSpecificParams;

import com.android.server.backup.fullbackup.ShadowSigningInfo;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;

import java.io.IOException;
import java.util.HexFormat;
import java.util.List;

@RunWith(RobolectricTestRunner.class)
@Config(
        shadows = {
            ShadowSigningInfo.class,
        })
public class CrossPlatformManifestParserTest {
    private static final String PACKAGE_NAME = "com.example.app";
    private static final String PLATFORM = "ios";
    private static final String BUNDLE_ID = "com.example.bundleid";
    private static final String TEAM_ID = "A1B2C3D4";
    private static final String CONTENT_VERSION = "1.0";
    private static final String SIGNATURE_HASH_1 = "1234";
    private static final String SIGNATURE_HASH_2 = "5678";

    @Test
    public void parse_validManifest_returnsManifest() throws IOException {
        String manifestContent =
                "1\n"
                        + PACKAGE_NAME
                        + "\n"
                        + PLATFORM
                        + "\n"
                        + "1\n"
                        + BUNDLE_ID
                        + "\n"
                        + TEAM_ID
                        + "\n"
                        + CONTENT_VERSION
                        + "\n"
                        + "2\n"
                        + SIGNATURE_HASH_1
                        + "\n"
                        + SIGNATURE_HASH_2
                        + "\n";

        CrossPlatformManifest manifest =
                CrossPlatformManifestParser.parse(manifestContent.getBytes());

        assertThat(manifest).isNotNull();
        assertThat(manifest.getPackageName()).isEqualTo(PACKAGE_NAME);
        assertThat(manifest.getPlatform()).isEqualTo(PLATFORM);
        assertThat(manifest.getPlatformSpecificParams())
                .containsExactly(new PlatformSpecificParams(BUNDLE_ID, TEAM_ID, CONTENT_VERSION));
        List<byte[]> signatureHashes = manifest.getSignatureHashes();
        assertThat(signatureHashes).hasSize(2);
        assertThat(signatureHashes.get(0)).isEqualTo(HexFormat.of().parseHex(SIGNATURE_HASH_1));
        assertThat(signatureHashes.get(1)).isEqualTo(HexFormat.of().parseHex(SIGNATURE_HASH_2));
    }

    @Test
    public void parse_unsupportedVersion_throwsException() {
        String manifestContent = "0\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e).hasMessageThat().isEqualTo("Unsupported cross-platform manifest version: 0");
    }

    @Test
    public void parse_emptyManifest_throwsException() {
        byte[] manifestBytes = new byte[0];

        IOException e =
                assertThrows(
                        IOException.class, () -> CrossPlatformManifestParser.parse(manifestBytes));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Unsupported cross-platform manifest version: null");
    }

    @Test
    public void parse_missingPackageName_throwsException() {
        String manifestContent = "1\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing package");
    }

    @Test
    public void parse_missingPlatform_throwsException() {
        String manifestContent = "1\n" + PACKAGE_NAME + "\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing platform");
    }

    @Test
    public void parse_missingParamsSize_throwsException() {
        String manifestContent = "1\n" + PACKAGE_NAME + "\n" + PLATFORM + "\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing params");
    }

    @Test
    public void parse_missingSignaturesSize_throwsException() {
        String manifestContent = "1\n" + PACKAGE_NAME + "\n" + PLATFORM + "\n" + "0\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing signatures");
    }

    @Test
    public void parse_incompletePlatformSpecificParams_throwsException() {
        String manifestContent =
                "1\n"
                        + PACKAGE_NAME
                        + "\n"
                        + PLATFORM
                        + "\n"
                        + "1\n"
                        + BUNDLE_ID
                        + "\n"
                        + TEAM_ID
                        + "\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing content version parameter");
    }

    @Test
    public void parse_incompleteSignatures_throwsException() {
        String manifestContent =
                "1\n"
                        + PACKAGE_NAME
                        + "\n"
                        + PLATFORM
                        + "\n"
                        + "0\n"
                        + "2\n"
                        + SIGNATURE_HASH_1
                        + "\n";

        IOException e =
                assertThrows(
                        IOException.class,
                        () -> CrossPlatformManifestParser.parse(manifestContent.getBytes()));
        assertThat(e)
                .hasMessageThat()
                .isEqualTo("Incomplete cross-platform manifest: missing signature");
    }

    @Test
    public void parse_noPlatformParamsAndNoSignatures_returnsManifest() throws IOException {
        String manifestContent = "1\n" + PACKAGE_NAME + "\n" + PLATFORM + "\n" + "0\n" + "0\n";

        CrossPlatformManifest manifest =
                CrossPlatformManifestParser.parse(manifestContent.getBytes());

        assertThat(manifest).isNotNull();
        assertThat(manifest.getPackageName()).isEqualTo(PACKAGE_NAME);
        assertThat(manifest.getPlatform()).isEqualTo(PLATFORM);
        assertThat(manifest.getPlatformSpecificParams()).isEmpty();
        assertThat(manifest.getSignatureHashes()).isEmpty();
    }

    @Test
    public void parse_multiplePlatformParamsAndSignatures_returnsManifest() throws IOException {
        String manifestContent =
                "1\n"
                        + PACKAGE_NAME
                        + "\n"
                        + PLATFORM
                        + "\n"
                        + "2\n"
                        + BUNDLE_ID
                        + "_1\n"
                        + TEAM_ID
                        + "_1\n"
                        + CONTENT_VERSION
                        + "_1\n"
                        + BUNDLE_ID
                        + "_2\n"
                        + TEAM_ID
                        + "_2\n"
                        + CONTENT_VERSION
                        + "_2\n"
                        + "2\n"
                        + SIGNATURE_HASH_1
                        + "\n"
                        + SIGNATURE_HASH_2
                        + "\n";

        CrossPlatformManifest manifest =
                CrossPlatformManifestParser.parse(manifestContent.getBytes());

        assertThat(manifest).isNotNull();
        assertThat(manifest.getPackageName()).isEqualTo(PACKAGE_NAME);
        assertThat(manifest.getPlatform()).isEqualTo(PLATFORM);

        List<PlatformSpecificParams> platformSpecificParams = manifest.getPlatformSpecificParams();
        assertThat(platformSpecificParams).hasSize(2);
        assertThat(platformSpecificParams.get(0).getBundleId()).isEqualTo(BUNDLE_ID + "_1");
        assertThat(platformSpecificParams.get(0).getTeamId()).isEqualTo(TEAM_ID + "_1");
        assertThat(platformSpecificParams.get(0).getContentVersion())
                .isEqualTo(CONTENT_VERSION + "_1");
        assertThat(platformSpecificParams.get(1).getBundleId()).isEqualTo(BUNDLE_ID + "_2");
        assertThat(platformSpecificParams.get(1).getTeamId()).isEqualTo(TEAM_ID + "_2");
        assertThat(platformSpecificParams.get(1).getContentVersion())
                .isEqualTo(CONTENT_VERSION + "_2");

        List<byte[]> signatureHashes = manifest.getSignatureHashes();
        assertThat(signatureHashes).hasSize(2);
        assertThat(signatureHashes.get(0)).isEqualTo(HexFormat.of().parseHex(SIGNATURE_HASH_1));
        assertThat(signatureHashes.get(1)).isEqualTo(HexFormat.of().parseHex(SIGNATURE_HASH_2));
    }
}
+135 −0

File added.

Preview size limit exceeded, changes collapsed.