| // Copyright 2022 The ChromiumOS Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "init/startup/stateful_mount.h" |
| |
| #include <fcntl.h> |
| #include <sys/mount.h> |
| #include <sys/stat.h> |
| #include <sys/types.h> |
| #include <time.h> |
| |
| #include <algorithm> |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <utility> |
| |
| #include <base/files/file_enumerator.h> |
| #include <base/files/file_path.h> |
| #include <base/files/file_util.h> |
| #include <base/json/json_reader.h> |
| #include <base/logging.h> |
| #include <base/strings/string_number_conversions.h> |
| #include <base/strings/string_split.h> |
| #include <base/strings/string_util.h> |
| #include <base/values.h> |
| #include <brillo/blkdev_utils/lvm.h> |
| #include <brillo/blkdev_utils/storage_utils.h> |
| #include <brillo/files/file_util.h> |
| #include <brillo/process/process.h> |
| #include <brillo/key_value_store.h> |
| #include <brillo/secure_blob.h> |
| #include <metrics/bootstat.h> |
| #include <rootdev/rootdev.h> |
| |
| #include "init/startup/constants.h" |
| #include "init/startup/flags.h" |
| #include "init/startup/platform_impl.h" |
| #include "init/utils.h" |
| |
| namespace { |
| |
| constexpr char kQuota[] = "proc/sys/fs/quota"; |
| constexpr char kExt4Features[] = "sys/fs/ext4/features"; |
| constexpr char kReservedBlocksGID[] = "20119"; |
| constexpr char kQuotaOpt[] = "quota"; |
| constexpr char kQuotaProjectOpt[] = "project"; |
| constexpr char kDumpe2fsStatefulLog[] = "run/dumpe2fs_stateful.log"; |
| constexpr char kDirtyExpireCentisecs[] = "proc/sys/vm/dirty_expire_centisecs"; |
| |
| constexpr char kHiberman[] = "usr/sbin/hiberman"; |
| constexpr char kHiberResumeInitLog[] = "run/hibernate/hiber-resume-init.log"; |
| constexpr char kDevMapper[] = "dev/mapper"; |
| constexpr char kUnencryptedRW[] = "unencrypted-rw"; |
| constexpr char kDevImageRW[] = "dev-image-rw"; |
| |
| constexpr char kUpdateAvailable[] = ".update_available"; |
| constexpr char kLabMachine[] = ".labmachine"; |
| |
| constexpr char kVar[] = "var"; |
| constexpr char kNew[] = "_new"; |
| constexpr char kOverlay[] = "_overlay"; |
| constexpr char kDevImage[] = "dev_image"; |
| |
| // TODO(asavery): update the check for removable devices to be |
| // more advanced, b/209476959 |
| bool RemovableRootdev(const base::FilePath& path, int* ret) { |
| base::FilePath removable("/sys/block"); |
| removable = removable.Append(path.BaseName()); |
| removable = removable.Append("removable"); |
| return utils::ReadFileToInt(removable, ret); |
| } |
| |
| uint64_t GetDirtyExpireCentisecs(const base::FilePath& root) { |
| std::string dirty_expire; |
| uint64_t dirty_expire_centisecs = 0; |
| base::FilePath centisecs_path = root.Append(kDirtyExpireCentisecs); |
| if (!base::ReadFileToString(centisecs_path, &dirty_expire)) { |
| PLOG(WARNING) << "Failed to read " << centisecs_path.value(); |
| return 0; |
| } |
| |
| base::TrimWhitespaceASCII(dirty_expire, base::TRIM_ALL, &dirty_expire); |
| if (!base::StringToUint64(dirty_expire, &dirty_expire_centisecs)) { |
| PLOG(WARNING) << "Failed to parse contents of " << centisecs_path.value(); |
| } |
| return dirty_expire_centisecs; |
| } |
| |
| // TODO(asavery): Use ext2fs library directly instead since we only use |
| // a subset of the information provided, b/241965074. |
| bool Dumpe2fs(const base::FilePath& path, |
| const std::vector<std::string>& args, |
| std::string* info) { |
| brillo::ProcessImpl dump; |
| dump.AddArg("/sbin/dumpe2fs"); |
| dump.AddArg("-h"); |
| for (const std::string& arg : args) { |
| dump.AddArg(arg); |
| } |
| dump.AddArg(path.value()); |
| |
| dump.RedirectOutputToMemory(true); |
| if (dump.Run() == 0) { |
| *info = dump.GetOutputString(STDOUT_FILENO); |
| return true; |
| } |
| PLOG(WARNING) << "dumpe2fs failed"; |
| *info = ""; |
| return false; |
| } |
| |
| bool IsFeatureEnabled(const std::string& fs_features, |
| const std::string& feature) { |
| // Check if feature is already enabled. |
| return fs_features.find(feature) != std::string::npos; |
| } |
| |
| bool IsReservedGidSet(const std::string& state_dumpe2fs) { |
| std::size_t rbg_pos = state_dumpe2fs.find("Reserved blocks gid"); |
| if (rbg_pos != std::string::npos) { |
| std::size_t nwl_pos = state_dumpe2fs.find("\n", rbg_pos); |
| std::string rbg = state_dumpe2fs.substr(rbg_pos, nwl_pos); |
| if (rbg.find(kReservedBlocksGID) == std::string::npos) { |
| return false; |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| void AppendOption(const std::string& fs_features, |
| std::vector<std::string>* sb_options, |
| const std::string& option_name) { |
| if (!IsFeatureEnabled(fs_features, option_name)) { |
| sb_options->push_back(option_name); |
| } |
| } |
| |
| // Get the partition number for the given key, |
| // e.g. "PARTITION_NUM_STATE". Fails with a `CHECK()` if any error occurs. |
| int GetPartitionNumFromImageVars(const base::Value& image_vars, |
| base::StringPiece key) { |
| const base::Value::Dict& dict = image_vars.GetDict(); |
| const std::string* value = dict.FindString(key); |
| CHECK_NE(value, nullptr); |
| int num = 0; |
| CHECK(base::StringToInt(*value, &num)); |
| return num; |
| } |
| |
| } // namespace |
| |
| namespace startup { |
| |
| StatefulMount::StatefulMount(const Flags& flags, |
| const base::FilePath& root, |
| const base::FilePath& stateful, |
| Platform* platform, |
| std::unique_ptr<brillo::LogicalVolumeManager> lvm) |
| : flags_(flags), |
| root_(root), |
| stateful_(stateful), |
| platform_(platform), |
| lvm_(std::move(lvm)) {} |
| |
| bool StatefulMount::GetImageVars(base::FilePath json_file, |
| std::string key, |
| base::Value* vars) { |
| std::string json_string; |
| if (!base::ReadFileToString(json_file, &json_string)) { |
| PLOG(ERROR) << "Unable to read json file: " << json_file; |
| return false; |
| } |
| auto part_vars = base::JSONReader::ReadAndReturnValueWithError( |
| json_string, base::JSON_PARSE_RFC); |
| if (!part_vars.has_value()) { |
| PLOG(ERROR) << "Failed to parse image variables."; |
| return false; |
| } |
| if (!part_vars->is_dict()) { |
| LOG(ERROR) << "Failed to read json file as a dictionary"; |
| return false; |
| } |
| |
| base::Value* image_vars = part_vars->FindDictKey(key); |
| if (image_vars == nullptr) { |
| LOG(ERROR) << "Failed to get image variables from " << json_file; |
| return false; |
| } |
| *vars = std::move(*image_vars); |
| return true; |
| } |
| |
| bool StatefulMount::IsQuotaEnabled() { |
| base::FilePath quota = root_.Append(kQuota); |
| return base::DirectoryExists(quota); |
| } |
| |
| void StatefulMount::AppendQuotaFeaturesAndOptions( |
| const std::string& fs_features, |
| const std::string& state_dumpe2fs, |
| std::vector<std::string>* sb_options, |
| std::vector<std::string>* sb_features) { |
| // Enable/disable quota feature. |
| if (!IsReservedGidSet(state_dumpe2fs)) { |
| // Add Android's AID_RESERVED_DISK to resgid. |
| sb_features->push_back("-g"); |
| sb_features->push_back(kReservedBlocksGID); |
| } |
| |
| if (IsQuotaEnabled()) { |
| // Quota is enabled in the kernel, make sure that quota is enabled in |
| // the filesystem |
| if (!IsFeatureEnabled(fs_features, kQuotaOpt)) { |
| sb_options->push_back(kQuotaOpt); |
| sb_features->push_back("-Qusrquota,grpquota"); |
| } |
| std::optional<bool> prjquota_sup = flags_.prjquota; |
| bool prjquota = prjquota_sup.value_or(false); |
| if (prjquota) { |
| if (!IsFeatureEnabled(fs_features, kQuotaProjectOpt)) { |
| sb_features->push_back("-Qprjquota"); |
| } |
| } else { |
| if (IsFeatureEnabled(fs_features, kQuotaProjectOpt)) { |
| sb_features->push_back("-Q^prjquota"); |
| } |
| } |
| } else { |
| // Quota is not enabled in the kernel, make sure that quota is disabled |
| // in the filesystem. |
| if (IsFeatureEnabled(fs_features, kQuotaOpt)) { |
| sb_options->push_back("^quota"); |
| sb_features->push_back("-Q^usrquota,^grpquota,^prjquota"); |
| } |
| } |
| } |
| |
| void StatefulMount::EnableExt4Features() { |
| std::vector<std::string> sb_features = GenerateExt4FeaturesWrapper(); |
| |
| if (!sb_features.empty()) { |
| brillo::ProcessImpl tune2fs; |
| tune2fs.AddArg("/sbin/tune2fs"); |
| for (const std::string& arg : sb_features) { |
| tune2fs.AddArg(arg); |
| } |
| tune2fs.AddArg(state_dev_.value()); |
| tune2fs.RedirectOutputToMemory(true); |
| int status = tune2fs.Run(); |
| if (status != 0) { |
| PLOG(ERROR) << "tune2fs failed with status: " << status; |
| } |
| } |
| } |
| |
| std::vector<std::string> StatefulMount::GenerateExt4FeaturesWrapper() { |
| std::string state_dumpe2fs; |
| std::vector<std::string> args; |
| if (!Dumpe2fs(state_dev_, args, &state_dumpe2fs)) { |
| PLOG(ERROR) << "Failed dumpe2fs for stateful partition."; |
| } |
| return GenerateExt4Features(state_dumpe2fs); |
| } |
| |
| std::vector<std::string> StatefulMount::GenerateExt4Features( |
| const std::string state_dumpe2fs) { |
| std::vector<std::string> sb_features; |
| std::vector<std::string> sb_options; |
| std::string feat; |
| std::string fs_features; |
| std::size_t feature_pos = state_dumpe2fs.find("Filesystem features:"); |
| if (feature_pos != std::string::npos) { |
| std::size_t nl_pos = state_dumpe2fs.find("\n", feature_pos); |
| fs_features = state_dumpe2fs.substr(feature_pos, nl_pos); |
| } |
| |
| std::optional<bool> direncrypt = flags_.direncryption; |
| bool direncryption = direncrypt.value_or(false); |
| base::FilePath encryption = root_.Append(kExt4Features).Append("encryption"); |
| if (direncryption && base::PathExists(encryption)) { |
| AppendOption(fs_features, &sb_options, "encrypt"); |
| } |
| |
| std::optional<bool> fsverity = flags_.fsverity; |
| bool verity = fsverity.value_or(false); |
| base::FilePath verity_file = root_.Append(kExt4Features).Append("verity"); |
| if (verity && base::PathExists(verity_file)) { |
| AppendOption(fs_features, &sb_options, "verity"); |
| } |
| |
| AppendQuotaFeaturesAndOptions(fs_features, state_dumpe2fs, &sb_options, |
| &sb_features); |
| |
| if (!sb_features.empty() || !sb_options.empty()) { |
| // Ensure to replay the journal first so it doesn't overwrite the flag. |
| platform_->ReplayExt4Journal(state_dev_); |
| |
| if (!sb_options.empty()) { |
| std::string opts = base::JoinString(sb_options, ","); |
| sb_features.push_back("-O"); |
| sb_features.push_back(opts); |
| } |
| } |
| |
| return sb_features; |
| } |
| |
| // Check to see if this a hibernate resume boot. |
| bool StatefulMount::HibernateResumeBoot() { |
| base::FilePath hiberman_cmd = root_.Append(kHiberman); |
| base::FilePath hiber_init_log = root_.Append(kHiberResumeInitLog); |
| return (base::PathExists(hiberman_cmd) && |
| platform_->RunHiberman(hiber_init_log)); |
| } |
| |
| void StatefulMount::MountStateful() { |
| base::FilePath root_dev; |
| // Prepare to mount stateful partition. |
| bool rootdev_ret = utils::GetRootDevice(&root_dev_type_, true); |
| int removable = 0; |
| if (!RemovableRootdev(root_dev_type_, &removable)) { |
| PLOG(WARNING) |
| << "Unable to read if rootdev is removable; assuming it's not"; |
| } |
| std::string load_vars; |
| if (removable == 1) { |
| load_vars = "load_partition_vars"; |
| } else { |
| load_vars = "load_base_vars"; |
| } |
| |
| base::Value image_vars; |
| base::FilePath json_file = root_.Append("usr/sbin/partition_vars.json"); |
| if (!StatefulMount::GetImageVars(json_file, load_vars, &image_vars)) { |
| return; |
| } |
| |
| bool status; |
| int32_t stateful_mount_flags; |
| std::string stateful_mount_opts; |
| |
| // Check if we are booted on physical media. rootdev will fail if we are in |
| // an initramfs or tmpfs rootfs (ex, factory installer images. Note recovery |
| // image also uses initramfs but it never reaches here). When using |
| // initrd+tftpboot (some old netboot factory installer), ROOTDEV_TYPE will be |
| // /dev/ram. |
| if (rootdev_ret && root_dev_type_ != base::FilePath("/dev/ram")) { |
| // Find our stateful partition mount point. |
| stateful_mount_flags = kCommonMountFlags | MS_NOATIME; |
| const int part_num_state = |
| GetPartitionNumFromImageVars(image_vars, "PARTITION_NUM_STATE"); |
| const std::string* fs_form_state = |
| image_vars.FindStringKey("FS_FORMAT_STATE"); |
| state_dev_ = brillo::AppendPartition(root_dev_type_, part_num_state); |
| if (fs_form_state->compare("ext4") == 0) { |
| int dirty_expire_centisecs = GetDirtyExpireCentisecs(root_); |
| int commit_interval = dirty_expire_centisecs / 100; |
| if (commit_interval != 0) { |
| stateful_mount_opts = "commit=" + std::to_string(commit_interval); |
| stateful_mount_opts.append(",discard"); |
| } else { |
| LOG(INFO) << "Using default value for commit interval"; |
| stateful_mount_opts = "discard"; |
| } |
| } |
| |
| std::optional<bool> lvm_stateful = flags_.lvm_stateful; |
| bool lvm_enable = lvm_stateful.value_or(false); |
| if (lvm_enable) { |
| // Attempt to get a valid volume group name. |
| bootstat_.LogEvent("pre-lvm-activation"); |
| auto pv = lvm_->GetPhysicalVolume(state_dev_); |
| if (pv && pv->IsValid()) { |
| auto vg = lvm_->GetVolumeGroup(*pv); |
| if (vg && vg->IsValid()) { |
| // Check to see if this a hibernate resume boot. If so, |
| // the image that will soon be resumed has active mounts |
| // on the stateful LVs that must not be modified out from underneath |
| // the hibernated kernel. Ask hiberman to activate the necessary |
| // logical volumes and set up dm-snapshots on top of them to make a RW |
| // system while leaving those LVs physically intact. |
| if (HibernateResumeBoot()) { |
| base::FilePath dev_mapper = root_.Append(kDevMapper); |
| state_dev_ = dev_mapper.Append(kUnencryptedRW); |
| dev_image_ = dev_mapper.Append(kDevImageRW); |
| } else { |
| auto lg = lvm_->GetLogicalVolume(vg.value(), "unencrypted"); |
| lg->Activate(); |
| state_dev_ = |
| root_.Append("dev").Append(vg->GetName()).Append("unencrypted"); |
| dev_image_ = |
| root_.Append("dev").Append(vg->GetName()).Append("dev-image"); |
| } |
| } |
| } |
| bootstat_.LogEvent("lvm-activation-complete"); |
| } |
| |
| EnableExt4Features(); |
| |
| // Mount stateful partition from state_dev. |
| if (!platform_->Mount(state_dev_, base::FilePath("/mnt/stateful_partition"), |
| fs_form_state->c_str(), stateful_mount_flags, |
| stateful_mount_opts)) { |
| // Try to rebuild the stateful partition by clobber-state. (Not using fast |
| // mode out of security consideration: the device might have gotten into |
| // this state through power loss during dev mode transition). |
| platform_->BootAlert("self_repair"); |
| |
| platform_->ClobberLogRepair(state_dev_, |
| "'Self-repair corrupted stateful partition'"); |
| |
| std::vector<std::string> dump_args = {"-f"}; |
| std::string output; |
| status = Dumpe2fs(state_dev_, dump_args, &output); |
| base::FilePath log = root_.Append(kDumpe2fsStatefulLog); |
| if (!status || !base::WriteFile(base::FilePath(log), output)) { |
| PLOG(ERROR) << "Failed to write dumpe2fs output to " |
| << kDumpe2fsStatefulLog; |
| } |
| |
| std::vector<std::string> crash_args{"--mount_failure", |
| "--mount_device=stateful"}; |
| platform_->AddClobberCrashReport(crash_args); |
| std::vector<std::string> argv{"keepimg"}; |
| platform_->Clobber(argv); |
| } |
| |
| // Mount the OEM partition. |
| // mount_or_fail isn't used since this partition only has a filesystem |
| // on some boards. |
| int32_t oem_flags = MS_RDONLY | kCommonMountFlags; |
| const int part_num_oem = |
| GetPartitionNumFromImageVars(image_vars, "PARTITION_NUM_OEM"); |
| const std::string* fs_form_oem = image_vars.FindStringKey("FS_FORMAT_OEM"); |
| const base::FilePath oem_dev = |
| brillo::AppendPartition(root_dev_type_, part_num_oem); |
| status = platform_->Mount(oem_dev, base::FilePath("/usr/share/oem"), |
| *fs_form_oem, oem_flags, ""); |
| if (!status) { |
| PLOG(WARNING) << "mount of /usr/share/oem failed with code " << status; |
| } |
| } |
| } |
| |
| void StatefulMount::SetStateDevForTest(const base::FilePath& dev) { |
| state_dev_ = dev; |
| } |
| |
| base::FilePath StatefulMount::GetStateDev() { |
| return state_dev_; |
| } |
| |
| base::FilePath StatefulMount::GetDevImage() { |
| return dev_image_; |
| } |
| |
| // Updates stateful partition if pending |
| // update is available. |
| // Returns true if there is no need to update or successful update. |
| bool StatefulMount::DevUpdateStatefulPartition(const std::string& args) { |
| base::FilePath stateful_update_file = stateful_.Append(kUpdateAvailable); |
| std::string stateful_update_args = args; |
| if (stateful_update_args.empty()) { |
| if (!base::ReadFileToString(stateful_update_file, &stateful_update_args)) { |
| PLOG(WARNING) << "Failed to read from " << stateful_update_file.value(); |
| return true; |
| } |
| } |
| |
| // To remain compatible with the prior update_stateful tarballs, expect |
| // the "var_new" unpack location, but move it into the new "var_overlay" |
| // target location. |
| std::string var(kVar); |
| std::string dev_image(kDevImage); |
| base::FilePath var_new = stateful_.Append(var + kNew); |
| base::FilePath developer_new = stateful_.Append(dev_image + kNew); |
| base::FilePath developer_target = stateful_.Append(dev_image); |
| base::FilePath var_target = stateful_.Append(var + kOverlay); |
| std::vector<base::FilePath> paths_to_rm; |
| |
| // Only replace the developer and var_overlay directories if new replacements |
| // are available. |
| if (base::DirectoryExists(developer_new) && base::DirectoryExists(var_new)) { |
| std::string update = "'Updating from " + developer_new.value() + " && " + |
| var_new.value() + ".'"; |
| platform_->ClobberLog(update); |
| |
| for (const std::string& path : {var, dev_image}) { |
| base::FilePath path_new = stateful_.Append(path + kNew); |
| base::FilePath path_target; |
| if (path == "var") { |
| path_target = stateful_.Append(path + kOverlay); |
| } else { |
| path_target = stateful_.Append(path); |
| } |
| if (!brillo::DeletePathRecursively(path_target)) { |
| PLOG(WARNING) << "Failed to delete " << path_target.value(); |
| } |
| |
| if (!base::CreateDirectory(path_target)) { |
| PLOG(WARNING) << "Failed to create " << path_target.value(); |
| } |
| |
| if (!base::SetPosixFilePermissions(path_target, 0755)) { |
| PLOG(WARNING) << "chmod failed for " << path_target.value(); |
| } |
| |
| base::FileEnumerator enumerator(path_new, false /* recursive */, |
| base::FileEnumerator::FILES | |
| base::FileEnumerator::DIRECTORIES | |
| base::FileEnumerator::SHOW_SYM_LINKS); |
| |
| for (base::FilePath fd = enumerator.Next(); !fd.empty(); |
| fd = enumerator.Next()) { |
| if (!base::Move(fd, path_target.Append(fd.BaseName()))) { |
| PLOG(WARNING) << "Failed to copy " << fd.value() << " to " |
| << path_target.value(); |
| } |
| } |
| paths_to_rm.push_back(path_new); |
| } |
| platform_->RemoveInBackground(paths_to_rm); |
| } else { |
| std::string update = "'Stateful update did not find " + |
| developer_new.value() + " & " + var_new.value() + |
| ".'\n'Keeping old development tools.'"; |
| platform_->ClobberLog(update); |
| } |
| |
| // Check for clobber. |
| if (stateful_update_args.compare("clobber") == 0) { |
| base::FilePath preserve_dir = stateful_.Append("unencrypted/preserve"); |
| |
| // Find everything in stateful and delete it, except for protected paths, |
| // and non-empty directories. The non-empty directories contain protected |
| // content or they would already be empty from depth first traversal. |
| std::vector<base::FilePath> preserved_paths = { |
| stateful_.Append(kLabMachine), developer_target, var_target, |
| preserve_dir}; |
| base::FileEnumerator enumerator(stateful_, true, |
| base::FileEnumerator::FILES | |
| base::FileEnumerator::DIRECTORIES | |
| base::FileEnumerator::SHOW_SYM_LINKS); |
| for (auto path = enumerator.Next(); !path.empty(); |
| path = enumerator.Next()) { |
| bool preserve = false; |
| for (auto& preserved_path : preserved_paths) { |
| if (path == preserved_path || preserved_path.IsParent(path) || |
| path.IsParent(preserved_path)) { |
| preserve = true; |
| break; |
| } |
| } |
| |
| if (!preserve) { |
| if (base::DirectoryExists(path)) { |
| brillo::DeletePathRecursively(path); |
| } else { |
| brillo::DeleteFile(path); |
| } |
| } |
| } |
| // Let's really be done before coming back. |
| sync(); |
| } |
| |
| std::vector<base::FilePath> rm_paths{stateful_update_file}; |
| platform_->RemoveInBackground(rm_paths); |
| |
| return true; |
| } |
| |
| // Gather logs. |
| void StatefulMount::DevGatherLogs(const base::FilePath& base_dir) { |
| // For dev/test images, if .gatherme presents, copy files listed in .gatherme |
| // to /mnt/stateful_partition/unencrypted/prior_logs. |
| base::FilePath lab_preserve_logs = stateful_.Append(".gatherme"); |
| base::FilePath prior_log_dir = stateful_.Append("unencrypted/prior_logs"); |
| std::string log_path; |
| |
| if (!base::PathExists(lab_preserve_logs)) { |
| return; |
| } |
| |
| std::string files; |
| base::ReadFileToString(lab_preserve_logs, &files); |
| std::vector<std::string> split_files = base::SplitString( |
| files, "\n", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY); |
| for (std::string log_path : split_files) { |
| if (log_path.find("#") != std::string::npos) { |
| continue; |
| } |
| base::FilePath log(log_path); |
| if (base::DirectoryExists(log)) { |
| if (!base::CopyDirectory(log, prior_log_dir, true)) { |
| PLOG(WARNING) << "Failed to copy directory " << log_path; |
| } |
| } else { |
| if (!base::CopyFile(log, prior_log_dir.Append(log.BaseName()))) { |
| PLOG(WARNING) << "Failed to copy file " << log_path; |
| } |
| } |
| } |
| |
| if (!brillo::DeleteFile(lab_preserve_logs)) { |
| PLOG(WARNING) << "Failed to delete file: " << lab_preserve_logs; |
| } |
| } |
| |
| } // namespace startup |