| // Copyright 2020 The Chromium OS Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package cache |
| |
| import ( |
| "bytes" |
| "context" |
| "fmt" |
| "io/ioutil" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| |
| "chromiumos/tast/common/testexec" |
| "chromiumos/tast/errors" |
| "chromiumos/tast/fsutil" |
| "chromiumos/tast/local/arc" |
| "chromiumos/tast/local/chrome" |
| "chromiumos/tast/testing" |
| ) |
| |
| // PackagesMode represents a flag that determines whether packages_cache.xml |
| // will be copied within ARC after boot. |
| type PackagesMode int |
| |
| const ( |
| // PackagesCopy forces to set caches and copy packages cache to the preserved destination. |
| PackagesCopy PackagesMode = iota |
| // PackagesSkipCopy forces to ignore caches and copy packages cache to the preserved destination. |
| PackagesSkipCopy |
| ) |
| |
| // GMSCoreMode represents a flag that determines whether existing GMS Core caches |
| // would be used or not. |
| type GMSCoreMode int |
| |
| const ( |
| // GMSCoreEnabled requires to use existing GMS Core caches if they are available. |
| GMSCoreEnabled GMSCoreMode = iota |
| // GMSCoreDisabled requires not to use existing GMS Core caches. |
| GMSCoreDisabled |
| ) |
| |
| // pathCondition represents whether waitForAndroidPath should wait for the path |
| // to be created or to be removed. |
| type pathCondition int |
| |
| const ( |
| pathMustExist pathCondition = iota |
| pathMustNotExist |
| ) |
| |
| const ( |
| // LayoutTxt defines output file name that contains generated file directory layout and |
| // file attributes. |
| LayoutTxt = "layout.txt" |
| // PackagesCacheXML defines the name of packages cache file name. |
| PackagesCacheXML = "packages_cache.xml" |
| // GeneratedPackagesCacheXML defines the name of pregenerated packages cache file name. |
| // Used to rename the cache file retrieved from /system/etc. |
| GeneratedPackagesCacheXML = "generated_packages_cache.xml" |
| // GMSCoreCacheArchive defines the GMS Core cache tar file name. |
| GMSCoreCacheArchive = "gms_core_cache.tar" |
| // GMSCoreManifest defines the GMS Core manifest file that lists GMS Core release files |
| // with size, modification time in milliseconds, and SHA256. |
| // of GMS Core release. |
| GMSCoreManifest = "gms_core_manifest" |
| // GSFCache defines the GSF cache database file name. |
| GSFCache = "gservices_cache.db" |
| // Timeout to wait GMS Core resources. |
| gmsCoreWaitTimeout = 2 * time.Minute |
| ) |
| |
| // OpenSession starts Chrome and ARC with caches turned on or off, depending on the mode parameter. |
| // On success, non-nil pointers are returned that must be closed by the calling function. |
| // However, if there is an error, both pointers will be nil |
| func OpenSession(ctx context.Context, packagesMode PackagesMode, gmsCoreMode GMSCoreMode, extraArgs []string, outputDir string) (cr *chrome.Chrome, a *arc.ARC, retErr error) { |
| args := arc.DisableSyncFlags() |
| args = append(args, "--arc-disable-download-provider") |
| switch packagesMode { |
| case PackagesSkipCopy: |
| args = append(args, "--arc-packages-cache-mode=skip-copy") |
| case PackagesCopy: |
| args = append(args, "--arc-packages-cache-mode=copy") |
| default: |
| return nil, nil, errors.Errorf("invalid packagesMode %d passed", packagesMode) |
| } |
| switch gmsCoreMode { |
| case GMSCoreEnabled: |
| case GMSCoreDisabled: |
| args = append(args, "--arc-disable-gms-core-cache") |
| default: |
| return nil, nil, errors.Errorf("invalid gmsCoreMode %d passed", gmsCoreMode) |
| } |
| args = append(args, extraArgs...) |
| |
| // Signs in as chrome.DefaultUser. |
| cr, err := chrome.New(ctx, chrome.ARCEnabled(), chrome.ExtraArgs(args...)) |
| if err != nil { |
| return nil, nil, errors.Wrap(err, "failed to login to Chrome") |
| } |
| |
| a, err = arc.New(ctx, outputDir) |
| if err != nil { |
| cr.Close(ctx) |
| return nil, nil, errors.Wrap(err, "could not start ARC") |
| } |
| return cr, a, nil |
| } |
| |
| // CopyCaches waits for required caches are ready and copies them to the specified output directory. |
| func CopyCaches(ctx context.Context, a *arc.ARC, outputDir string) error { |
| const ( |
| gmsRoot = "/data/user_de/0/com.google.android.gms" |
| appChimera = "app_chimera" |
| packagesPath = "/data/system/packages_copy.xml" |
| gsfDatabase = "/data/data/com.google.android.gsf/databases/gservices.db" |
| ) |
| |
| // OpenSession signs in as chrome.DefaultUser. |
| androidDataDir, err := arc.AndroidDataDir(ctx, chrome.DefaultUser) |
| if err != nil { |
| return errors.Wrap(err, "failed to get android-data path") |
| } |
| |
| gmsRootUnderHome := filepath.Join(androidDataDir, gmsRoot) |
| chimeraPath := filepath.Join(gmsRootUnderHome, appChimera) |
| for _, e := range []struct { |
| filename string |
| cond pathCondition |
| }{ |
| {"current_config.fb", pathMustExist}, |
| {"current_fileapks.pb", pathMustExist}, |
| {"current_features.fb", pathMustExist}, |
| {"stored_modulesets.pb", pathMustExist}, |
| {"current_modules_init.pb", pathMustNotExist}, |
| {"pending_modules_init.pb", pathMustNotExist}, |
| } { |
| if err := waitForPath(ctx, filepath.Join(chimeraPath, e.filename), e.cond); err != nil { |
| return err |
| } |
| } |
| |
| version, err := arc.SDKVersion() |
| if err != nil { |
| return errors.Wrap(err, "failed to get SDK version") |
| } |
| |
| if err := waitForApksOptimized(ctx, chimeraPath, version); err != nil { |
| return err |
| } |
| |
| targetTar := filepath.Join(outputDir, GMSCoreCacheArchive) |
| |
| testing.ContextLogf(ctx, "Compressing GMS Core caches to %q", targetTar) |
| if err := testexec.CommandContext(ctx, "tar", "-cvpf", targetTar, "-C", gmsRootUnderHome, appChimera).Run(testexec.DumpLogOnError); err != nil { |
| return errors.Wrap(err, "failed to compress GMS Core caches") |
| } |
| |
| testing.ContextLogf(ctx, "Collecting stat results for files under %q", chimeraPath) |
| |
| // statResult holds data obtained by stat command. |
| type statResult struct { |
| path string |
| accessRights string // Obtained by "stat -c %a" |
| numBlocks string // Obtained by "stat -c %b" |
| } |
| var statResults []statResult |
| filepath.Walk(chimeraPath, func(path string, info os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| |
| // Strip "/home/root/${USER_HASH}/android-data" prefix from the path. |
| androidPath := strings.Replace(path, androidDataDir, "", 1) |
| |
| info, err = os.Lstat(path) |
| if err != nil { |
| return errors.Wrapf(err, "failed to stat %q", path) |
| } |
| if info.Mode()&os.ModeSymlink == os.ModeSymlink { |
| // File is a symbolic link. |
| realPath, err := os.Readlink(path) |
| if err != nil { |
| return errors.Wrapf(err, "failed to get real path for %q", path) |
| } |
| if !strings.HasPrefix(realPath, "/data/") { |
| // File is a symbolic link to a file outside of /data/ |
| // and it is not exposed on CrOS file system. |
| // Run stat command on Android file system via adb shell. |
| out, err := a.Command( |
| ctx, "stat", "-c", "%a:%b", realPath).Output(testexec.DumpLogOnError) |
| statVals := strings.Split(strings.TrimSpace(string(out)), ":") |
| if err != nil || len(statVals) != 2 { |
| return errors.Wrapf(err, "failed to stat %q : %q", realPath, string(out)) |
| } |
| statResults = append(statResults, |
| statResult{path: androidPath, accessRights: statVals[0], numBlocks: statVals[1]}) |
| return nil |
| } |
| } |
| |
| // File is not a symbolic link, or a symbolic link to a file under /data/, |
| // which is exposed on CrOS file system. Run stat command on CrOS. |
| out, err := testexec.CommandContext(ctx, "stat", "-c", "%a:%b", path).Output() |
| statVals := strings.Split(strings.TrimSpace(string(out)), ":") |
| if err != nil || len(statVals) != 2 { |
| return errors.Wrapf(err, "failed to stat %q : %q", path, string(out)) |
| } |
| statResults = append(statResults, |
| statResult{path: androidPath, accessRights: statVals[0], numBlocks: statVals[1]}) |
| return nil |
| }) |
| |
| testing.ContextLogf(ctx, "Generating %q from collected stat results", LayoutTxt) |
| var layoutLines []string |
| for _, r := range statResults { |
| layoutLines = append(layoutLines, fmt.Sprintf("%s:%s:%s", r.path, r.accessRights, r.numBlocks)) |
| } |
| sort.Strings(layoutLines) |
| layout := strings.Join(layoutLines, "\n") |
| if err := ioutil.WriteFile(filepath.Join(outputDir, LayoutTxt), []byte(layout), 0644); err != nil { |
| return errors.Wrapf(err, "failed to generate %q for %q", LayoutTxt, chimeraPath) |
| } |
| |
| // Packages cache |
| src := filepath.Join(androidDataDir, packagesPath) |
| packagesPathLocal := filepath.Join(outputDir, PackagesCacheXML) |
| if err := fsutil.CopyFile(src, packagesPathLocal); err != nil { |
| return err |
| } |
| |
| // GSF cache |
| src = filepath.Join(androidDataDir, gsfDatabase) |
| dst := filepath.Join(outputDir, GSFCache) |
| if err := fsutil.CopyFile(src, dst); err != nil { |
| return err |
| } |
| |
| // Extract GMS Core location and create manifest for this directory. |
| b, err := ioutil.ReadFile(packagesPathLocal) |
| if err != nil { |
| return errors.Wrapf(err, "failed to read %q", packagesPathLocal) |
| } |
| if !bytes.HasPrefix(b, []byte("<?xml ")) { |
| // This file is a binary XML. Convert it to a text XML using abx2xml. |
| // Read from stdin ("-") and write to stdout ("-"). |
| cmd := a.Command(ctx, "abx2xml", "-", "-") |
| cmd.Stdin = bytes.NewBuffer(b) |
| b, err = cmd.Output(testexec.DumpLogOnError) |
| if err != nil { |
| return errors.Wrap(err, "abx2xml failed") |
| } |
| } |
| |
| gmsCorePath := regexp.MustCompile(`<package name=\"com\.google\.android\.gms\".+codePath=\"(\S+)\".+>`).FindStringSubmatch(string(b)) |
| if gmsCorePath == nil { |
| return errors.Wrapf(err, "failed to parse %q", packagesPathLocal) |
| } |
| |
| manifestPath := filepath.Join(outputDir, GMSCoreManifest) |
| testing.ContextLogf(ctx, "Capturing GMS Core manifest for %q to %q", gmsCorePath[1], GMSCoreManifest) |
| // stat -c "%n %s" "$0" gives name and file size in bytes |
| // date +%s%N -r "$0" | cut -b1-13 gives modification time with millisecond resolution. |
| // sha256sum -b "$0" gives sha256 check sum |
| // tr \"\n\" \" \" to remove new line ending and have 3 commands outputs in one line. |
| const perFileCmd = `stat -c "%n %s" "$0" | tr "\n" " " && date +%s%N -r "$0" | cut -b1-13 | tr "\n" " " && sha256sum -b "$0"` |
| out, err := a.Command( |
| ctx, "/system/bin/find", "-L", gmsCorePath[1], "-type", "f", "-exec", "sh", "-c", perFileCmd, "{}", ";").Output(testexec.DumpLogOnError) |
| if err != nil { |
| return errors.Wrapf(err, "failed to create GMS Core manifiest: %q", string(out)) |
| } |
| |
| if err := ioutil.WriteFile(manifestPath, out, 0644); err != nil { |
| return errors.Wrapf(err, "failed to save GMS Core manifest to %q", manifestPath) |
| } |
| |
| return nil |
| } |
| |
| // waitForPath waits up to 1 minute or ctx deadline for the specified path to exist or not |
| // exist depending on pathCondition c. |
| func waitForPath(ctx context.Context, path string, c pathCondition) error { |
| testing.ContextLogf(ctx, "Waiting for path %q", path) |
| if err := testing.Poll(ctx, func(ctx context.Context) error { |
| _, err := os.Stat(path) |
| if err != nil && !os.IsNotExist(err) { |
| return testing.PollBreak(errors.Wrapf(err, "failed to stat %s", path)) |
| } |
| exists := err == nil |
| if c == pathMustExist { |
| if !exists { |
| return errors.Wrapf(err, "path %s still does not exist", path) |
| } |
| } else { |
| if exists { |
| return errors.Wrapf(err, "path %s still exists", path) |
| } |
| } |
| return nil |
| }, &testing.PollOptions{Timeout: gmsCoreWaitTimeout, Interval: time.Second}); err != nil { |
| return errors.Wrapf(err, "failed to wait for path %s", path) |
| } |
| return nil |
| } |
| |
| // waitForApksOptimized waits up to 1 minute or ctx deadline for all APKs in given root path are |
| // optimized, which means no *.flock locks and *.odex/*.vdex exist and matches actual APK count |
| // on PI and below. |
| func waitForApksOptimized(ctx context.Context, root string, sdkVersion int) error { |
| testing.ContextLogf(ctx, "Waiting for APKs optimized %q", root) |
| if err := testing.Poll(ctx, func(ctx context.Context) error { |
| // Calculate number of files per extension. |
| perExtCnt := map[string]int{} |
| // Modes for root of odex files. |
| odexParentModes := map[string]os.FileMode{} |
| err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { |
| if err != nil { |
| return err |
| } |
| if !info.IsDir() { |
| ext := filepath.Ext(info.Name()) |
| perExtCnt[ext] = perExtCnt[ext] + 1 |
| if ext == ".odex" { |
| parent := filepath.Dir(path) |
| parentInfo, err := os.Stat(parent) |
| if err != nil { |
| return errors.Wrapf(err, "failed to stat odex parent %s", path) |
| } |
| odexParentModes[parent] = parentInfo.Mode() |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| return testing.PollBreak(errors.Wrapf(err, "failed to walk %q", root)) |
| } |
| |
| for odexParent, mode := range odexParentModes { |
| // Make sure parent has execute bits. |
| if mode&0111 != 0111 { |
| return errors.Errorf("odex parent %q has no execution bits in mode %s", odexParent, mode.String()) |
| } |
| } |
| |
| apkCnt := perExtCnt[".apk"] |
| if perExtCnt[".flock"] != 0 { |
| return errors.Errorf("file lock detected in %q", root) |
| } |
| if apkCnt == 0 { |
| return testing.PollBreak(errors.Errorf("no APK found in %q", root)) |
| } |
| vdexCnt := perExtCnt[".vdex"] |
| odexCnt := perExtCnt[".odex"] |
| // Match internal GMS Core logic: |
| // https://source.corp.google.com/piper///depot/google3/java/com/google/android/gmscore/integ/libs/chimera/module/src/com/google/android/chimera/container/DexOptUtils.java;l=34 |
| if sdkVersion < arc.SDKQ { |
| if apkCnt != vdexCnt || apkCnt != odexCnt { |
| return errors.Errorf("not everything yet optimized; APK count: %d, vdex: %d, odex: %d; expected each APK has odex and vdex", apkCnt, vdexCnt, odexCnt) |
| } |
| } else { |
| if vdexCnt != 0 || odexCnt != 0 { |
| return testing.PollBreak(errors.Errorf("optimization is not expected in Q+; APK count: %d, vdex: %d, odex: %d", apkCnt, vdexCnt, odexCnt)) |
| } |
| } |
| return nil |
| }, &testing.PollOptions{Timeout: gmsCoreWaitTimeout, Interval: time.Second}); err != nil { |
| return errors.Wrapf(err, "failed to wait for APKs optimized %s", root) |
| } |
| return nil |
| } |