/* * Copyright (C) 2011 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 android.app.backup; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.XmlResourceParser; import android.os.*; import android.os.Process; import android.system.ErrnoException; import android.system.Os; import android.text.TextUtils; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import org.xmlpull.v1.XmlPullParser; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; import org.xmlpull.v1.XmlPullParserException; /** * Global constant definitions et cetera related to the full-backup-to-fd * binary format. Nothing in this namespace is part of any API; it's all * hidden details of the current implementation gathered into one location. * * @hide */ public class FullBackup { static final String TAG = "FullBackup"; /** Enable this log tag to get verbose information while parsing the client xml. */ static final String TAG_XML_PARSER = "BackupXmlParserLogging"; public static final String APK_TREE_TOKEN = "a"; public static final String OBB_TREE_TOKEN = "obb"; public static final String ROOT_TREE_TOKEN = "r"; public static final String DATA_TREE_TOKEN = "f"; public static final String NO_BACKUP_TREE_TOKEN = "nb"; public static final String DATABASE_TREE_TOKEN = "db"; public static final String SHAREDPREFS_TREE_TOKEN = "sp"; public static final String MANAGED_EXTERNAL_TREE_TOKEN = "ef"; public static final String CACHE_TREE_TOKEN = "c"; public static final String SHARED_STORAGE_TOKEN = "shared"; public static final String APPS_PREFIX = "apps/"; public static final String SHARED_PREFIX = SHARED_STORAGE_TOKEN + "/"; public static final String FULL_BACKUP_INTENT_ACTION = "fullback"; public static final String FULL_RESTORE_INTENT_ACTION = "fullrest"; public static final String CONF_TOKEN_INTENT_EXTRA = "conftoken"; /** * @hide */ static public native int backupToTar(String packageName, String domain, String linkdomain, String rootpath, String path, FullBackupDataOutput output); private static final Map<String, BackupScheme> kPackageBackupSchemeMap = new ArrayMap<String, BackupScheme>(); static synchronized BackupScheme getBackupScheme(Context context) { BackupScheme backupSchemeForPackage = kPackageBackupSchemeMap.get(context.getPackageName()); if (backupSchemeForPackage == null) { backupSchemeForPackage = new BackupScheme(context); kPackageBackupSchemeMap.put(context.getPackageName(), backupSchemeForPackage); } return backupSchemeForPackage; } public static BackupScheme getBackupSchemeForTest(Context context) { BackupScheme testing = new BackupScheme(context); testing.mExcludes = new ArraySet(); testing.mIncludes = new ArrayMap(); return testing; } /** * Copy data from a socket to the given File location on permanent storage. The * modification time and access mode of the resulting file will be set if desired, * although group/all rwx modes will be stripped: the restored file will not be * accessible from outside the target application even if the original file was. * If the {@code type} parameter indicates that the result should be a directory, * the socket parameter may be {@code null}; even if it is valid, no data will be * read from it in this case. * <p> * If the {@code mode} argument is negative, then the resulting output file will not * have its access mode or last modification time reset as part of this operation. * * @param data Socket supplying the data to be copied to the output file. If the * output is a directory, this may be {@code null}. * @param size Number of bytes of data to copy from the socket to the file. At least * this much data must be available through the {@code data} parameter. * @param type Must be either {@link BackupAgent#TYPE_FILE} for ordinary file data * or {@link BackupAgent#TYPE_DIRECTORY} for a directory. * @param mode Unix-style file mode (as used by the chmod(2) syscall) to be set on * the output file or directory. group/all rwx modes are stripped even if set * in this parameter. If this parameter is negative then neither * the mode nor the mtime values will be applied to the restored file. * @param mtime A timestamp in the standard Unix epoch that will be imposed as the * last modification time of the output file. if the {@code mode} parameter is * negative then this parameter will be ignored. * @param outFile Location within the filesystem to place the data. This must point * to a location that is writeable by the caller, preferably using an absolute path. * @throws IOException */ static public void restoreFile(ParcelFileDescriptor data, long size, int type, long mode, long mtime, File outFile) throws IOException { if (type == BackupAgent.TYPE_DIRECTORY) { // Canonically a directory has no associated content, so we don't need to read // anything from the pipe in this case. Just create the directory here and // drop down to the final metadata adjustment. if (outFile != null) outFile.mkdirs(); } else { FileOutputStream out = null; // Pull the data from the pipe, copying it to the output file, until we're done try { if (outFile != null) { File parent = outFile.getParentFile(); if (!parent.exists()) { // in practice this will only be for the default semantic directories, // and using the default mode for those is appropriate. // This can also happen for the case where a parent directory has been // excluded, but a file within that directory has been included. parent.mkdirs(); } out = new FileOutputStream(outFile); } } catch (IOException e) { Log.e(TAG, "Unable to create/open file " + outFile.getPath(), e); } byte[] buffer = new byte[32 * 1024]; final long origSize = size; FileInputStream in = new FileInputStream(data.getFileDescriptor()); while (size > 0) { int toRead = (size > buffer.length) ? buffer.length : (int)size; int got = in.read(buffer, 0, toRead); if (got <= 0) { Log.w(TAG, "Incomplete read: expected " + size + " but got " + (origSize - size)); break; } if (out != null) { try { out.write(buffer, 0, got); } catch (IOException e) { // Problem writing to the file. Quit copying data and delete // the file, but of course keep consuming the input stream. Log.e(TAG, "Unable to write to file " + outFile.getPath(), e); out.close(); out = null; outFile.delete(); } } size -= got; } if (out != null) out.close(); } // Now twiddle the state to match the backup, assuming all went well if (mode >= 0 && outFile != null) { try { // explicitly prevent emplacement of files accessible by outside apps mode &= 0700; Os.chmod(outFile.getPath(), (int)mode); } catch (ErrnoException e) { e.rethrowAsIOException(); } outFile.setLastModified(mtime); } } @VisibleForTesting public static class BackupScheme { private final File FILES_DIR; private final File DATABASE_DIR; private final File ROOT_DIR; private final File SHAREDPREF_DIR; private final File EXTERNAL_DIR; private final File CACHE_DIR; private final File NOBACKUP_DIR; final int mFullBackupContent; final PackageManager mPackageManager; final String mPackageName; /** * Parse out the semantic domains into the correct physical location. */ String tokenToDirectoryPath(String domainToken) { try { if (domainToken.equals(FullBackup.DATA_TREE_TOKEN)) { return FILES_DIR.getCanonicalPath(); } else if (domainToken.equals(FullBackup.DATABASE_TREE_TOKEN)) { return DATABASE_DIR.getCanonicalPath(); } else if (domainToken.equals(FullBackup.ROOT_TREE_TOKEN)) { return ROOT_DIR.getCanonicalPath(); } else if (domainToken.equals(FullBackup.SHAREDPREFS_TREE_TOKEN)) { return SHAREDPREF_DIR.getCanonicalPath(); } else if (domainToken.equals(FullBackup.CACHE_TREE_TOKEN)) { return CACHE_DIR.getCanonicalPath(); } else if (domainToken.equals(FullBackup.MANAGED_EXTERNAL_TREE_TOKEN)) { if (EXTERNAL_DIR != null) { return EXTERNAL_DIR.getCanonicalPath(); } else { return null; } } else if (domainToken.equals(FullBackup.NO_BACKUP_TREE_TOKEN)) { return NOBACKUP_DIR.getCanonicalPath(); } // Not a supported location Log.i(TAG, "Unrecognized domain " + domainToken); return null; } catch (IOException e) { Log.i(TAG, "Error reading directory for domain: " + domainToken); return null; } } /** * A map of domain -> list of canonical file names in that domain that are to be included. * We keep track of the domain so that we can go through the file system in order later on. */ Map<String, Set<String>> mIncludes; /**e * List that will be populated with the canonical names of each file or directory that is * to be excluded. */ ArraySet<String> mExcludes; BackupScheme(Context context) { mFullBackupContent = context.getApplicationInfo().fullBackupContent; mPackageManager = context.getPackageManager(); mPackageName = context.getPackageName(); FILES_DIR = context.getFilesDir(); DATABASE_DIR = context.getDatabasePath("foo").getParentFile(); ROOT_DIR = new File(context.getApplicationInfo().dataDir); SHAREDPREF_DIR = context.getSharedPrefsFile("foo").getParentFile(); CACHE_DIR = context.getCacheDir(); NOBACKUP_DIR = context.getNoBackupFilesDir(); if (android.os.Process.myUid() != Process.SYSTEM_UID) { EXTERNAL_DIR = context.getExternalFilesDir(null); } else { EXTERNAL_DIR = null; } } boolean isFullBackupContentEnabled() { if (mFullBackupContent < 0) { // android:fullBackupContent="false", bail. if (Log.isLoggable(FullBackup.TAG_XML_PARSER, Log.VERBOSE)) { Log.v(FullBackup.TAG_XML_PARSER, "android:fullBackupContent - \"false\""); } return false; } return true; } /** * @return A mapping of domain -> canonical paths within that domain. Each of these paths * specifies a file that the client has explicitly included in their backup set. If this * map is empty we will back up the entire data directory (including managed external * storage). */ public synchronized Map<String, Set<String>> maybeParseAndGetCanonicalIncludePaths() throws IOException, XmlPullParserException { if (mIncludes == null) { maybeParseBackupSchemeLocked(); } return mIncludes; } /** * @return A set of canonical paths that are to be excluded from the backup/restore set. */ public synchronized ArraySet<String> maybeParseAndGetCanonicalExcludePaths() throws IOException, XmlPullParserException { if (mExcludes == null) { maybeParseBackupSchemeLocked(); } return mExcludes; } private void maybeParseBackupSchemeLocked() throws IOException, XmlPullParserException { // This not being null is how we know that we've tried to parse the xml already. mIncludes = new ArrayMap<String, Set<String>>(); mExcludes = new ArraySet<String>(); if (mFullBackupContent == 0) { // android:fullBackupContent="true" which means that we'll do everything. if (Log.isLoggable(FullBackup.TAG_XML_PARSER, Log.VERBOSE)) { Log.v(FullBackup.TAG_XML_PARSER, "android:fullBackupContent - \"true\""); } } else { // android:fullBackupContent="@xml/some_resource". if (Log.isLoggable(FullBackup.TAG_XML_PARSER, Log.VERBOSE)) { Log.v(FullBackup.TAG_XML_PARSER, "android:fullBackupContent - found xml resource"); } XmlResourceParser parser = null; try { parser = mPackageManager .getResourcesForApplication(mPackageName) .getXml(mFullBackupContent); parseBackupSchemeFromXmlLocked(parser, mExcludes, mIncludes); } catch (PackageManager.NameNotFoundException e) { // Throw it as an IOException throw new IOException(e); } finally { if (parser != null) { parser.close(); } } } } @VisibleForTesting public void parseBackupSchemeFromXmlLocked(XmlPullParser parser, Set<String> excludes, Map<String, Set<String>> includes) throws IOException, XmlPullParserException { int event = parser.getEventType(); // START_DOCUMENT while (event != XmlPullParser.START_TAG) { event = parser.next(); } if (!"full-backup-content".equals(parser.getName())) { throw new XmlPullParserException("Xml file didn't start with correct tag" + " (<full-backup-content>). Found \"" + parser.getName() + "\""); } if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "\n"); Log.v(TAG_XML_PARSER, "===================================================="); Log.v(TAG_XML_PARSER, "Found valid fullBackupContent; parsing xml resource."); Log.v(TAG_XML_PARSER, "===================================================="); Log.v(TAG_XML_PARSER, ""); } while ((event = parser.next()) != XmlPullParser.END_DOCUMENT) { switch (event) { case XmlPullParser.START_TAG: validateInnerTagContents(parser); final String domainFromXml = parser.getAttributeValue(null, "domain"); final File domainDirectory = getDirectoryForCriteriaDomain(domainFromXml); if (domainDirectory == null) { if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "...parsing \"" + parser.getName() + "\": " + "domain=\"" + domainFromXml + "\" invalid; skipping"); } break; } final File canonicalFile = extractCanonicalFile(domainDirectory, parser.getAttributeValue(null, "path")); if (canonicalFile == null) { break; } Set<String> activeSet = parseCurrentTagForDomain( parser, excludes, includes, domainFromXml); activeSet.add(canonicalFile.getCanonicalPath()); if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "...parsed " + canonicalFile.getCanonicalPath() + " for domain \"" + domainFromXml + "\""); } // Special case journal files (not dirs) for sqlite database. frowny-face. // Note that for a restore, the file is never a directory (b/c it doesn't // exist). We have no way of knowing a priori whether or not to expect a // dir, so we add the -journal anyway to be safe. if ("database".equals(domainFromXml) && !canonicalFile.isDirectory()) { final String canonicalJournalPath = canonicalFile.getCanonicalPath() + "-journal"; activeSet.add(canonicalJournalPath); if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "...automatically generated " + canonicalJournalPath + ". Ignore if nonexistant."); } } } } if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "\n"); Log.v(TAG_XML_PARSER, "Xml resource parsing complete."); Log.v(TAG_XML_PARSER, "Final tally."); Log.v(TAG_XML_PARSER, "Includes:"); if (includes.isEmpty()) { Log.v(TAG_XML_PARSER, " ...nothing specified (This means the entirety of app" + " data minus excludes)"); } else { for (Map.Entry<String, Set<String>> entry : includes.entrySet()) { Log.v(TAG_XML_PARSER, " domain=" + entry.getKey()); for (String includeData : entry.getValue()) { Log.v(TAG_XML_PARSER, " " + includeData); } } } Log.v(TAG_XML_PARSER, "Excludes:"); if (excludes.isEmpty()) { Log.v(TAG_XML_PARSER, " ...nothing to exclude."); } else { for (String excludeData : excludes) { Log.v(TAG_XML_PARSER, " " + excludeData); } } Log.v(TAG_XML_PARSER, " "); Log.v(TAG_XML_PARSER, "===================================================="); Log.v(TAG_XML_PARSER, "\n"); } } private Set<String> parseCurrentTagForDomain(XmlPullParser parser, Set<String> excludes, Map<String, Set<String>> includes, String domain) throws XmlPullParserException { if ("include".equals(parser.getName())) { final String domainToken = getTokenForXmlDomain(domain); Set<String> includeSet = includes.get(domainToken); if (includeSet == null) { includeSet = new ArraySet<String>(); includes.put(domainToken, includeSet); } return includeSet; } else if ("exclude".equals(parser.getName())) { return excludes; } else { // Unrecognised tag => hard failure. if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "Invalid tag found in xml \"" + parser.getName() + "\"; aborting operation."); } throw new XmlPullParserException("Unrecognised tag in backup" + " criteria xml (" + parser.getName() + ")"); } } /** * Map xml specified domain (human-readable, what clients put in their manifest's xml) to * BackupAgent internal data token. * @return null if the xml domain was invalid. */ private String getTokenForXmlDomain(String xmlDomain) { if ("root".equals(xmlDomain)) { return FullBackup.ROOT_TREE_TOKEN; } else if ("file".equals(xmlDomain)) { return FullBackup.DATA_TREE_TOKEN; } else if ("database".equals(xmlDomain)) { return FullBackup.DATABASE_TREE_TOKEN; } else if ("sharedpref".equals(xmlDomain)) { return FullBackup.SHAREDPREFS_TREE_TOKEN; } else if ("external".equals(xmlDomain)) { return FullBackup.MANAGED_EXTERNAL_TREE_TOKEN; } else { return null; } } /** * * @param domain Directory where the specified file should exist. Not null. * @param filePathFromXml parsed from xml. Not sanitised before calling this function so may be * null. * @return The canonical path of the file specified or null if no such file exists. */ private File extractCanonicalFile(File domain, String filePathFromXml) { if (filePathFromXml == null) { // Allow things like <include domain="sharedpref"/> filePathFromXml = ""; } if (filePathFromXml.contains("..")) { if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "...resolved \"" + domain.getPath() + " " + filePathFromXml + "\", but the \"..\" path is not permitted; skipping."); } return null; } if (filePathFromXml.contains("//")) { if (Log.isLoggable(TAG_XML_PARSER, Log.VERBOSE)) { Log.v(TAG_XML_PARSER, "...resolved \"" + domain.getPath() + " " + filePathFromXml + "\", which contains the invalid \"//\" sequence; skipping."); } return null; } return new File(domain, filePathFromXml); } /** * @param domain parsed from xml. Not sanitised before calling this function so may be null. * @return The directory relevant to the domain specified. */ private File getDirectoryForCriteriaDomain(String domain) { if (TextUtils.isEmpty(domain)) { return null; } if ("file".equals(domain)) { return FILES_DIR; } else if ("database".equals(domain)) { return DATABASE_DIR; } else if ("root".equals(domain)) { return ROOT_DIR; } else if ("sharedpref".equals(domain)) { return SHAREDPREF_DIR; } else if ("external".equals(domain)) { return EXTERNAL_DIR; } else { return null; } } /** * Let's be strict about the type of xml the client can write. If we see anything untoward, * throw an XmlPullParserException. */ private void validateInnerTagContents(XmlPullParser parser) throws XmlPullParserException { if (parser.getAttributeCount() > 2) { throw new XmlPullParserException("At most 2 tag attributes allowed for \"" + parser.getName() + "\" tag (\"domain\" & \"path\"."); } if (!"include".equals(parser.getName()) && !"exclude".equals(parser.getName())) { throw new XmlPullParserException("A valid tag is one of \"<include/>\" or" + " \"<exclude/>. You provided \"" + parser.getName() + "\""); } } } }