chromium/chromeos/ash/components/trash_service/trash_service_impl.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chromeos/ash/components/trash_service/trash_service_impl.h"

#include <limits.h>

#include <string_view>
#include <utility>
#include <vector>

#include "base/check_op.h"
#include "base/containers/span.h"
#include "base/files/file.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_file.h"
#include "base/functional/callback.h"
#include "base/strings/escape.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/time/time.h"

namespace ash::trash_service {

namespace {

// Represents the expected token on the first line of the .trashinfo file.
constexpr std::string_view kTrashInfoHeaderToken = "[Trash Info]";

// Represents the expected token starting the second line of the .trashinfo
// file.
constexpr std::string_view kPathToken = "Path=";

// Represents the expected token starting the third line of the .trashinfo file.
constexpr std::string_view kDeletionDateToken = "DeletionDate=";

// The "DeletionDate=" line contains 24 bytes representing a well formed
// ISO-8601 date string, e.g. "2022-07-18T10:13:00.000Z".
constexpr size_t kISO8601Size = 24;

// Helper function to invoke the supplied callback with an error and empty
// restore path and deletion date.
void InvokeCallbackWithError(
    base::File::Error error,
    TrashServiceImpl::ParseTrashInfoFileCallback callback) {
  std::move(callback).Run(error, base::FilePath(), base::Time());
}

// Helper function to return `base::File::FILE_ERROR_FAILED` to the supplied
// callback.
void InvokeCallbackWithFailed(
    TrashServiceImpl::ParseTrashInfoFileCallback callback) {
  InvokeCallbackWithError(base::File::FILE_ERROR_FAILED, std::move(callback));
}

// Extracts and validates the path from a line coming from the `.trashinfo`
// file. Returns an empty path on error.
base::FilePath ValidateAndCreateRestorePath(std::string_view line) {
  // The final newline character should already have been stripped.
  DCHECK(!line.ends_with('\n'));

  if (!line.starts_with(kPathToken)) {
    LOG(ERROR) << "Line does not start with '" << kPathToken << "'";
    return base::FilePath();
  }

  line.remove_prefix(kPathToken.size());

  const std::string unescaped = base::UnescapeBinaryURLComponent(line);
  if (unescaped.size() >= PATH_MAX) {
    LOG(ERROR) << "Extracted path is too long";
    return base::FilePath();
  }

  if (unescaped.find('\0') != std::string::npos) {
    LOG(ERROR) << "Extracted path contains a NUL byte";
    return base::FilePath();
  }

  if (!base::IsStringUTF8(unescaped)) {
    LOG(ERROR) << "Extracted path is not a valid UTF-8 string";
    return base::FilePath();
  }

  const base::FilePath path(std::move(unescaped));

  const std::vector<std::string> components = path.GetComponents();
  base::span<const std::string> parts = components;

  // The first part should be "/".
  if (parts.empty() || parts.front() != "/") {
    LOG(ERROR) << "Extracted path is not absolute";
    return base::FilePath();
  }

  // Pop the first part.
  parts = parts.subspan(1);
  if (parts.empty()) {
    LOG(ERROR) << "Extracted path is just the root path";
    return base::FilePath();
  }

  // Validate each remaining part.
  for (const std::string& part : parts) {
    if (part == "." || part == ".." || part.size() > NAME_MAX) {
      LOG(ERROR) << "Extracted path contains an invalid component";
      return base::FilePath();
    }
  }

  return path;
}

// Extracts and validates the deletion date from a line coming from the
// `.trashinfo` file. Returns a default-created `Time` on error.
base::Time ValidateAndCreateDeletionDate(std::string_view line) {
  // The final newline character should already have been stripped.
  DCHECK(!line.ends_with('\n'));

  if (!line.starts_with(kDeletionDateToken)) {
    LOG(ERROR) << "Line does not start with '" << kDeletionDateToken << "'";
    return base::Time();
  }

  line.remove_prefix(kDeletionDateToken.size());

  base::Time date;
  if (line.size() != kISO8601Size ||
      !base::Time::FromUTCString(std::string(line).c_str(), &date)) {
    LOG(ERROR) << "Cannot parse date";
    return base::Time();
  }

  return date;
}

}  // namespace

TrashServiceImpl::TrashServiceImpl(
    mojo::PendingReceiver<mojom::TrashService> receiver) {
  receivers_.Add(this, std::move(receiver));
}

TrashServiceImpl::~TrashServiceImpl() = default;

void TrashServiceImpl::ParseTrashInfoFile(base::File trash_info_file,
                                          ParseTrashInfoFileCallback callback) {
  if (!trash_info_file.IsValid()) {
    InvokeCallbackWithError(trash_info_file.error_details(),
                            std::move(callback));
    return;
  }

  // Read the file up to the max buffer. In the event of a read error continue
  // trying to parse as this may represent the case where the buffer was
  // exceeded yet `file_contents` contains valid data after that point so
  // continue parsing.
  std::string file_contents;
  base::ScopedFILE read_only_stream(
      base::FileToFILE(std::move(trash_info_file), "r"));

  constexpr size_t kMaxSize = kTrashInfoHeaderToken.size() + kPathToken.size() +
                              PATH_MAX * 3 /* URL-escaping */ +
                              kDeletionDateToken.size() + kISO8601Size +
                              3 /* newline characters */;
  const bool ok = base::ReadStreamToStringWithMaxSize(read_only_stream.get(),
                                                      kMaxSize, &file_contents);
  if (!ok && file_contents.size() < kMaxSize) {
    LOG(ERROR) << "Cannot read trash info file";
    InvokeCallbackWithFailed(std::move(callback));
    return;
  }

  // Split the lines up and ignoring any empty lines in between. Only the first
  // 3 non-empty lines are useful to validate again.
  std::vector<std::string_view> lines = base::SplitStringPiece(
      file_contents, "\n", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY);
  if (lines.size() < 3) {
    LOG(ERROR) << "Trash info file only contains " << lines.size() << " lines";
    InvokeCallbackWithFailed(std::move(callback));
    return;
  }

  // The Trash spec says, "The implementation MUST ignore any other lines in
  // this file, except the first line (must be [Trash Info]) and these two
  // key/value pairs". Therefore we only iterate over the first 3 lines ignoring
  // the remaining.
  if (lines[0] != kTrashInfoHeaderToken) {
    LOG(ERROR) << "Invalid trash info header: " << lines[0];
    InvokeCallbackWithFailed(std::move(callback));
    return;
  }

  base::FilePath restore_path = ValidateAndCreateRestorePath(lines[1]);
  if (restore_path.empty()) {
    InvokeCallbackWithFailed(std::move(callback));
    return;
  }

  base::Time deletion_date = ValidateAndCreateDeletionDate(lines[2]);
  if (deletion_date.is_null()) {
    InvokeCallbackWithFailed(std::move(callback));
    return;
  }

  std::move(callback).Run(base::File::FILE_OK, std::move(restore_path),
                          std::move(deletion_date));
}

}  // namespace ash::trash_service