/* * Copyright (C) 2009 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.internal.backup; import android.app.backup.BackupDataInput; import android.app.backup.BackupDataOutput; import android.app.backup.BackupTransport; import android.app.backup.RestoreDescription; import android.app.backup.RestoreSet; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.system.ErrnoException; import android.system.Os; import android.system.StructStat; import android.util.ArrayMap; import android.util.Log; import com.android.org.bouncycastle.util.encoders.Base64; import libcore.io.IoUtils; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; /** * Backup transport for stashing stuff into a known location on disk, and * later restoring from there. For testing only. */ public class LocalTransport extends BackupTransport { private static final String TAG = "LocalTransport"; private static final boolean DEBUG = false; private static final String TRANSPORT_DIR_NAME = "com.android.internal.backup.LocalTransport"; private static final String TRANSPORT_DESTINATION_STRING = "Backing up to debug-only private cache"; private static final String TRANSPORT_DATA_MANAGEMENT_LABEL = ""; private static final String INCREMENTAL_DIR = "_delta"; private static final String FULL_DATA_DIR = "_full"; // The currently-active restore set always has the same (nonzero!) token private static final long CURRENT_SET_TOKEN = 1; // Size quotas at reasonable values, similar to the current cloud-storage limits private static final long FULL_BACKUP_SIZE_QUOTA = 25 * 1024 * 1024; private static final long KEY_VALUE_BACKUP_SIZE_QUOTA = 5 * 1024 * 1024; private Context mContext; private File mDataDir = new File(Environment.getDownloadCacheDirectory(), "backup"); private File mCurrentSetDir = new File(mDataDir, Long.toString(CURRENT_SET_TOKEN)); private File mCurrentSetIncrementalDir = new File(mCurrentSetDir, INCREMENTAL_DIR); private File mCurrentSetFullDir = new File(mCurrentSetDir, FULL_DATA_DIR); private PackageInfo[] mRestorePackages = null; private int mRestorePackage = -1; // Index into mRestorePackages private int mRestoreType; private File mRestoreSetDir; private File mRestoreSetIncrementalDir; private File mRestoreSetFullDir; // Additional bookkeeping for full backup private String mFullTargetPackage; private ParcelFileDescriptor mSocket; private FileInputStream mSocketInputStream; private BufferedOutputStream mFullBackupOutputStream; private byte[] mFullBackupBuffer; private long mFullBackupSize; private FileInputStream mCurFullRestoreStream; private FileOutputStream mFullRestoreSocketStream; private byte[] mFullRestoreBuffer; private void makeDataDirs() { mCurrentSetDir.mkdirs(); mCurrentSetFullDir.mkdir(); mCurrentSetIncrementalDir.mkdir(); } public LocalTransport(Context context) { mContext = context; makeDataDirs(); } @Override public String name() { return new ComponentName(mContext, this.getClass()).flattenToShortString(); } @Override public Intent configurationIntent() { // The local transport is not user-configurable return null; } @Override public String currentDestinationString() { return TRANSPORT_DESTINATION_STRING; } public Intent dataManagementIntent() { // The local transport does not present a data-management UI // TODO: consider adding simple UI to wipe the archives entirely, // for cleaning up the cache partition. return null; } public String dataManagementLabel() { return TRANSPORT_DATA_MANAGEMENT_LABEL; } @Override public String transportDirName() { return TRANSPORT_DIR_NAME; } @Override public long requestBackupTime() { // any time is a good time for local backup return 0; } @Override public int initializeDevice() { if (DEBUG) Log.v(TAG, "wiping all data"); deleteContents(mCurrentSetDir); makeDataDirs(); return TRANSPORT_OK; } // Encapsulation of a single k/v element change private class KVOperation { final String key; // Element filename, not the raw key, for efficiency final byte[] value; // null when this is a deletion operation KVOperation(String k, byte[] v) { key = k; value = v; } } @Override public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor data) { if (DEBUG) { try { StructStat ss = Os.fstat(data.getFileDescriptor()); Log.v(TAG, "performBackup() pkg=" + packageInfo.packageName + " size=" + ss.st_size); } catch (ErrnoException e) { Log.w(TAG, "Unable to stat input file in performBackup() on " + packageInfo.packageName); } } File packageDir = new File(mCurrentSetIncrementalDir, packageInfo.packageName); packageDir.mkdirs(); // Each 'record' in the restore set is kept in its own file, named by // the record key. Wind through the data file, extracting individual // record operations and building a list of all the updates to apply // in this update. final ArrayList changeOps; try { changeOps = parseBackupStream(data); } catch (IOException e) { // oops, something went wrong. abort the operation and return error. Log.v(TAG, "Exception reading backup input", e); return TRANSPORT_ERROR; } // Okay, now we've parsed out the delta's individual operations. We need to measure // the effect against what we already have in the datastore to detect quota overrun. // So, we first need to tally up the current in-datastore size per key. final ArrayMap datastore = new ArrayMap<>(); int totalSize = parseKeySizes(packageDir, datastore); // ... and now figure out the datastore size that will result from applying the // sequence of delta operations if (DEBUG) { if (changeOps.size() > 0) { Log.v(TAG, "Calculating delta size impact"); } else { Log.v(TAG, "No operations in backup stream, so no size change"); } } int updatedSize = totalSize; for (KVOperation op : changeOps) { // Deduct the size of the key we're about to replace, if any final Integer curSize = datastore.get(op.key); if (curSize != null) { updatedSize -= curSize.intValue(); if (DEBUG && op.value == null) { Log.v(TAG, " delete " + op.key + ", updated total " + updatedSize); } } // And add back the size of the value we're about to store, if any if (op.value != null) { updatedSize += op.value.length; if (DEBUG) { Log.v(TAG, ((curSize == null) ? " new " : " replace ") + op.key + ", updated total " + updatedSize); } } } // If our final size is over quota, report the failure if (updatedSize > KEY_VALUE_BACKUP_SIZE_QUOTA) { if (DEBUG) { Log.i(TAG, "New datastore size " + updatedSize + " exceeds quota " + KEY_VALUE_BACKUP_SIZE_QUOTA); } return TRANSPORT_QUOTA_EXCEEDED; } // No problem with storage size, so go ahead and apply the delta operations // (in the order that the app provided them) for (KVOperation op : changeOps) { File element = new File(packageDir, op.key); // this is either a deletion or a rewrite-from-zero, so we can just remove // the existing file and proceed in either case. element.delete(); // if this wasn't a deletion, put the new data in place if (op.value != null) { try (FileOutputStream out = new FileOutputStream(element)) { out.write(op.value, 0, op.value.length); } catch (IOException e) { Log.e(TAG, "Unable to update key file " + element); return TRANSPORT_ERROR; } } } return TRANSPORT_OK; } // Parses a backup stream into individual key/value operations private ArrayList parseBackupStream(ParcelFileDescriptor data) throws IOException { ArrayList changeOps = new ArrayList<>(); BackupDataInput changeSet = new BackupDataInput(data.getFileDescriptor()); while (changeSet.readNextHeader()) { String key = changeSet.getKey(); String base64Key = new String(Base64.encode(key.getBytes())); int dataSize = changeSet.getDataSize(); if (DEBUG) { Log.v(TAG, " Delta operation key " + key + " size " + dataSize + " key64 " + base64Key); } byte[] buf = (dataSize >= 0) ? new byte[dataSize] : null; if (dataSize >= 0) { changeSet.readEntityData(buf, 0, dataSize); } changeOps.add(new KVOperation(base64Key, buf)); } return changeOps; } // Reads the given datastore directory, building a table of the value size of each // keyed element, and returning the summed total. private int parseKeySizes(File packageDir, ArrayMap datastore) { int totalSize = 0; final String[] elements = packageDir.list(); if (elements != null) { if (DEBUG) { Log.v(TAG, "Existing datastore contents:"); } for (String file : elements) { File element = new File(packageDir, file); String key = file; // filename int size = (int) element.length(); totalSize += size; if (DEBUG) { Log.v(TAG, " key " + key + " size " + size); } datastore.put(key, size); } if (DEBUG) { Log.v(TAG, " TOTAL: " + totalSize); } } else { if (DEBUG) { Log.v(TAG, "No existing data for this package"); } } return totalSize; } // Deletes the contents but not the given directory private void deleteContents(File dirname) { File[] contents = dirname.listFiles(); if (contents != null) { for (File f : contents) { if (f.isDirectory()) { // delete the directory's contents then fall through // and delete the directory itself. deleteContents(f); } f.delete(); } } } @Override public int clearBackupData(PackageInfo packageInfo) { if (DEBUG) Log.v(TAG, "clearBackupData() pkg=" + packageInfo.packageName); File packageDir = new File(mCurrentSetIncrementalDir, packageInfo.packageName); final File[] fileset = packageDir.listFiles(); if (fileset != null) { for (File f : fileset) { f.delete(); } packageDir.delete(); } packageDir = new File(mCurrentSetFullDir, packageInfo.packageName); final File[] tarballs = packageDir.listFiles(); if (tarballs != null) { for (File f : tarballs) { f.delete(); } packageDir.delete(); } return TRANSPORT_OK; } @Override public int finishBackup() { if (DEBUG) Log.v(TAG, "finishBackup() of " + mFullTargetPackage); return tearDownFullBackup(); } // ------------------------------------------------------------------------------------ // Full backup handling private int tearDownFullBackup() { if (mSocket != null) { try { if (mFullBackupOutputStream != null) { mFullBackupOutputStream.flush(); mFullBackupOutputStream.close(); } mSocketInputStream = null; mFullTargetPackage = null; mSocket.close(); } catch (IOException e) { if (DEBUG) { Log.w(TAG, "Exception caught in tearDownFullBackup()", e); } return TRANSPORT_ERROR; } finally { mSocket = null; mFullBackupOutputStream = null; } } return TRANSPORT_OK; } private File tarballFile(String pkgName) { return new File(mCurrentSetFullDir, pkgName); } @Override public long requestFullBackupTime() { return 0; } @Override public int checkFullBackupSize(long size) { int result = TRANSPORT_OK; // Decline zero-size "backups" if (size <= 0) { result = TRANSPORT_PACKAGE_REJECTED; } else if (size > FULL_BACKUP_SIZE_QUOTA) { result = TRANSPORT_QUOTA_EXCEEDED; } if (result != TRANSPORT_OK) { if (DEBUG) { Log.v(TAG, "Declining backup of size " + size); } } return result; } @Override public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket) { if (mSocket != null) { Log.e(TAG, "Attempt to initiate full backup while one is in progress"); return TRANSPORT_ERROR; } if (DEBUG) { Log.i(TAG, "performFullBackup : " + targetPackage); } // We know a priori that we run in the system process, so we need to make // sure to dup() our own copy of the socket fd. Transports which run in // their own processes must not do this. try { mFullBackupSize = 0; mSocket = ParcelFileDescriptor.dup(socket.getFileDescriptor()); mSocketInputStream = new FileInputStream(mSocket.getFileDescriptor()); } catch (IOException e) { Log.e(TAG, "Unable to process socket for full backup"); return TRANSPORT_ERROR; } mFullTargetPackage = targetPackage.packageName; mFullBackupBuffer = new byte[4096]; return TRANSPORT_OK; } @Override public int sendBackupData(final int numBytes) { if (mSocket == null) { Log.w(TAG, "Attempted sendBackupData before performFullBackup"); return TRANSPORT_ERROR; } mFullBackupSize += numBytes; if (mFullBackupSize > FULL_BACKUP_SIZE_QUOTA) { return TRANSPORT_QUOTA_EXCEEDED; } if (numBytes > mFullBackupBuffer.length) { mFullBackupBuffer = new byte[numBytes]; } if (mFullBackupOutputStream == null) { FileOutputStream tarstream; try { File tarball = tarballFile(mFullTargetPackage); tarstream = new FileOutputStream(tarball); } catch (FileNotFoundException e) { return TRANSPORT_ERROR; } mFullBackupOutputStream = new BufferedOutputStream(tarstream); } int bytesLeft = numBytes; while (bytesLeft > 0) { try { int nRead = mSocketInputStream.read(mFullBackupBuffer, 0, bytesLeft); if (nRead < 0) { // Something went wrong if we expect data but saw EOD Log.w(TAG, "Unexpected EOD; failing backup"); return TRANSPORT_ERROR; } mFullBackupOutputStream.write(mFullBackupBuffer, 0, nRead); bytesLeft -= nRead; } catch (IOException e) { Log.e(TAG, "Error handling backup data for " + mFullTargetPackage); return TRANSPORT_ERROR; } } if (DEBUG) { Log.v(TAG, " stored " + numBytes + " of data"); } return TRANSPORT_OK; } // For now we can't roll back, so just tear everything down. @Override public void cancelFullBackup() { if (DEBUG) { Log.i(TAG, "Canceling full backup of " + mFullTargetPackage); } File archive = tarballFile(mFullTargetPackage); tearDownFullBackup(); if (archive.exists()) { archive.delete(); } } // ------------------------------------------------------------------------------------ // Restore handling static final long[] POSSIBLE_SETS = { 2, 3, 4, 5, 6, 7, 8, 9 }; @Override public RestoreSet[] getAvailableRestoreSets() { long[] existing = new long[POSSIBLE_SETS.length + 1]; int num = 0; // see which possible non-current sets exist... for (long token : POSSIBLE_SETS) { if ((new File(mDataDir, Long.toString(token))).exists()) { existing[num++] = token; } } // ...and always the currently-active set last existing[num++] = CURRENT_SET_TOKEN; RestoreSet[] available = new RestoreSet[num]; for (int i = 0; i < available.length; i++) { available[i] = new RestoreSet("Local disk image", "flash", existing[i]); } return available; } @Override public long getCurrentRestoreSet() { // The current restore set always has the same token return CURRENT_SET_TOKEN; } @Override public int startRestore(long token, PackageInfo[] packages) { if (DEBUG) Log.v(TAG, "start restore " + token + " : " + packages.length + " matching packages"); mRestorePackages = packages; mRestorePackage = -1; mRestoreSetDir = new File(mDataDir, Long.toString(token)); mRestoreSetIncrementalDir = new File(mRestoreSetDir, INCREMENTAL_DIR); mRestoreSetFullDir = new File(mRestoreSetDir, FULL_DATA_DIR); return TRANSPORT_OK; } @Override public RestoreDescription nextRestorePackage() { if (DEBUG) { Log.v(TAG, "nextRestorePackage() : mRestorePackage=" + mRestorePackage + " length=" + mRestorePackages.length); } if (mRestorePackages == null) throw new IllegalStateException("startRestore not called"); boolean found = false; while (++mRestorePackage < mRestorePackages.length) { String name = mRestorePackages[mRestorePackage].packageName; // If we have key/value data for this package, deliver that // skip packages where we have a data dir but no actual contents String[] contents = (new File(mRestoreSetIncrementalDir, name)).list(); if (contents != null && contents.length > 0) { if (DEBUG) { Log.v(TAG, " nextRestorePackage(TYPE_KEY_VALUE) @ " + mRestorePackage + " = " + name); } mRestoreType = RestoreDescription.TYPE_KEY_VALUE; found = true; } if (!found) { // No key/value data; check for [non-empty] full data File maybeFullData = new File(mRestoreSetFullDir, name); if (maybeFullData.length() > 0) { if (DEBUG) { Log.v(TAG, " nextRestorePackage(TYPE_FULL_STREAM) @ " + mRestorePackage + " = " + name); } mRestoreType = RestoreDescription.TYPE_FULL_STREAM; mCurFullRestoreStream = null; // ensure starting from the ground state found = true; } } if (found) { return new RestoreDescription(name, mRestoreType); } if (DEBUG) { Log.v(TAG, " ... package @ " + mRestorePackage + " = " + name + " has no data; skipping"); } } if (DEBUG) Log.v(TAG, " no more packages to restore"); return RestoreDescription.NO_MORE_PACKAGES; } @Override public int getRestoreData(ParcelFileDescriptor outFd) { if (mRestorePackages == null) throw new IllegalStateException("startRestore not called"); if (mRestorePackage < 0) throw new IllegalStateException("nextRestorePackage not called"); if (mRestoreType != RestoreDescription.TYPE_KEY_VALUE) { throw new IllegalStateException("getRestoreData(fd) for non-key/value dataset"); } File packageDir = new File(mRestoreSetIncrementalDir, mRestorePackages[mRestorePackage].packageName); // The restore set is the concatenation of the individual record blobs, // each of which is a file in the package's directory. We return the // data in lexical order sorted by key, so that apps which use synthetic // keys like BLOB_1, BLOB_2, etc will see the date in the most obvious // order. ArrayList blobs = contentsByKey(packageDir); if (blobs == null) { // nextRestorePackage() ensures the dir exists, so this is an error Log.e(TAG, "No keys for package: " + packageDir); return TRANSPORT_ERROR; } // We expect at least some data if the directory exists in the first place if (DEBUG) Log.v(TAG, " getRestoreData() found " + blobs.size() + " key files"); BackupDataOutput out = new BackupDataOutput(outFd.getFileDescriptor()); try { for (DecodedFilename keyEntry : blobs) { File f = keyEntry.file; FileInputStream in = new FileInputStream(f); try { int size = (int) f.length(); byte[] buf = new byte[size]; in.read(buf); if (DEBUG) Log.v(TAG, " ... key=" + keyEntry.key + " size=" + size); out.writeEntityHeader(keyEntry.key, size); out.writeEntityData(buf, size); } finally { in.close(); } } return TRANSPORT_OK; } catch (IOException e) { Log.e(TAG, "Unable to read backup records", e); return TRANSPORT_ERROR; } } static class DecodedFilename implements Comparable { public File file; public String key; public DecodedFilename(File f) { file = f; key = new String(Base64.decode(f.getName())); } @Override public int compareTo(DecodedFilename other) { // sorts into ascending lexical order by decoded key return key.compareTo(other.key); } } // Return a list of the files in the given directory, sorted lexically by // the Base64-decoded file name, not by the on-disk filename private ArrayList contentsByKey(File dir) { File[] allFiles = dir.listFiles(); if (allFiles == null || allFiles.length == 0) { return null; } // Decode the filenames into keys then sort lexically by key ArrayList contents = new ArrayList(); for (File f : allFiles) { contents.add(new DecodedFilename(f)); } Collections.sort(contents); return contents; } @Override public void finishRestore() { if (DEBUG) Log.v(TAG, "finishRestore()"); if (mRestoreType == RestoreDescription.TYPE_FULL_STREAM) { resetFullRestoreState(); } mRestoreType = 0; } // ------------------------------------------------------------------------------------ // Full restore handling private void resetFullRestoreState() { IoUtils.closeQuietly(mCurFullRestoreStream); mCurFullRestoreStream = null; mFullRestoreSocketStream = null; mFullRestoreBuffer = null; } /** * Ask the transport to provide data for the "current" package being restored. The * transport then writes some data to the socket supplied to this call, and returns * the number of bytes written. The system will then read that many bytes and * stream them to the application's agent for restore, then will call this method again * to receive the next chunk of the archive. This sequence will be repeated until the * transport returns zero indicating that all of the package's data has been delivered * (or returns a negative value indicating some sort of hard error condition at the * transport level). * *

After this method returns zero, the system will then call * {@link #getNextFullRestorePackage()} to begin the restore process for the next * application, and the sequence begins again. * * @param socket The file descriptor that the transport will use for delivering the * streamed archive. * @return 0 when no more data for the current package is available. A positive value * indicates the presence of that much data to be delivered to the app. A negative * return value is treated as equivalent to {@link BackupTransport#TRANSPORT_ERROR}, * indicating a fatal error condition that precludes further restore operations * on the current dataset. */ @Override public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) { if (mRestoreType != RestoreDescription.TYPE_FULL_STREAM) { throw new IllegalStateException("Asked for full restore data for non-stream package"); } // first chunk? if (mCurFullRestoreStream == null) { final String name = mRestorePackages[mRestorePackage].packageName; if (DEBUG) Log.i(TAG, "Starting full restore of " + name); File dataset = new File(mRestoreSetFullDir, name); try { mCurFullRestoreStream = new FileInputStream(dataset); } catch (IOException e) { // If we can't open the target package's tarball, we return the single-package // error code and let the caller go on to the next package. Log.e(TAG, "Unable to read archive for " + name); return TRANSPORT_PACKAGE_REJECTED; } mFullRestoreSocketStream = new FileOutputStream(socket.getFileDescriptor()); mFullRestoreBuffer = new byte[2*1024]; } int nRead; try { nRead = mCurFullRestoreStream.read(mFullRestoreBuffer); if (nRead < 0) { // EOF: tell the caller we're done nRead = NO_MORE_DATA; } else if (nRead == 0) { // This shouldn't happen when reading a FileInputStream; we should always // get either a positive nonzero byte count or -1. Log the situation and // treat it as EOF. Log.w(TAG, "read() of archive file returned 0; treating as EOF"); nRead = NO_MORE_DATA; } else { if (DEBUG) { Log.i(TAG, " delivering restore chunk: " + nRead); } mFullRestoreSocketStream.write(mFullRestoreBuffer, 0, nRead); } } catch (IOException e) { return TRANSPORT_ERROR; // Hard error accessing the file; shouldn't happen } finally { // Most transports will need to explicitly close 'socket' here, but this transport // is in the same process as the caller so it can leave it up to the backup manager // to manage both socket fds. } return nRead; } /** * If the OS encounters an error while processing {@link RestoreDescription#TYPE_FULL_STREAM} * data for restore, it will invoke this method to tell the transport that it should * abandon the data download for the current package. The OS will then either call * {@link #nextRestorePackage()} again to move on to restoring the next package in the * set being iterated over, or will call {@link #finishRestore()} to shut down the restore * operation. * * @return {@link #TRANSPORT_OK} if the transport was successful in shutting down the * current stream cleanly, or {@link #TRANSPORT_ERROR} to indicate a serious * transport-level failure. If the transport reports an error here, the entire restore * operation will immediately be finished with no further attempts to restore app data. */ @Override public int abortFullRestore() { if (mRestoreType != RestoreDescription.TYPE_FULL_STREAM) { throw new IllegalStateException("abortFullRestore() but not currently restoring"); } resetFullRestoreState(); mRestoreType = 0; return TRANSPORT_OK; } @Override public long getBackupQuota(String packageName, boolean isFullBackup) { return isFullBackup ? FULL_BACKUP_SIZE_QUOTA : KEY_VALUE_BACKUP_SIZE_QUOTA; } }