chromium/ios/chrome/browser/sessions/model/session_internal_util.mm

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

#import "ios/chrome/browser/sessions/model/session_internal_util.h"

#import "base/apple/foundation_util.h"
#import "base/files/file_path.h"
#import "base/logging.h"
#import "base/strings/sys_string_conversions.h"
#import "ios/chrome/browser/sessions/model/session_ios.h"
#import "ios/chrome/browser/sessions/model/session_window_ios.h"
#import "third_party/protobuf/src/google/protobuf/message_lite.h"

namespace ios::sessions {
namespace internal {

// Indicate the status for a path.
enum class PathStatus {
  kFile,
  kDirectory,
  kInexistent,
};

// Checks the status of `path`.
[[nodiscard]] PathStatus GetPathStatus(NSString* path) {
  BOOL is_directory = NO;
  if (![[NSFileManager defaultManager] fileExistsAtPath:path
                                            isDirectory:&is_directory]) {
    return PathStatus::kInexistent;
  }
  return is_directory ? PathStatus::kDirectory : PathStatus::kFile;
}

// Returns whether a file named `filename` exists.
[[nodiscard]] bool FileExists(NSString* filename) {
  return GetPathStatus(filename) == PathStatus::kFile;
}

// Returns whether a directory named `dirname` exists.
[[nodiscard]] bool DirectoryExists(NSString* dirname) {
  return GetPathStatus(dirname) == PathStatus::kDirectory;
}

// Creates `directory` including all intermediate directories and returns
// whether the operation was a success. Safe to call if `directory` exists.
[[nodiscard]] bool CreateDirectory(NSString* directory) {
  NSError* error = nil;
  if (![[NSFileManager defaultManager] createDirectoryAtPath:directory
                                 withIntermediateDirectories:YES
                                                  attributes:nil
                                                       error:&error]) {
    DLOG(WARNING) << "Error creating directory: "
                  << base::SysNSStringToUTF8(directory) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Renames a file from `from` to `dest`.
[[nodiscard]] bool RenameFile(NSString* from, NSString* dest) {
  if (!CreateDirectory([dest stringByDeletingLastPathComponent])) {
    return false;
  }

  NSError* error = nil;
  if (![[NSFileManager defaultManager] moveItemAtPath:from
                                               toPath:dest
                                                error:&error]) {
    DLOG(WARNING) << "Error moving file from: " << base::SysNSStringToUTF8(from)
                  << " to: " << base::SysNSStringToUTF8(dest) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Returns whether `directory` exists and is empty.
[[nodiscard]] bool DirectoryEmpty(NSString* directory) {
  if (!DirectoryExists(directory)) {
    return false;
  }

  NSDirectoryEnumerator<NSString*>* enumerator =
      [[NSFileManager defaultManager] enumeratorAtPath:directory];

  return [enumerator nextObject] == nil;
}

// Deletes recursively file or directory at `path`. Returns whether the
// operation was a success.
[[nodiscard]] bool DeleteRecursively(NSString* path) {
  NSError* error = nil;
  if (![[NSFileManager defaultManager] removeItemAtPath:path error:&error]) {
    DLOG(WARNING) << "Error removing file/directory at: "
                  << base::SysNSStringToUTF8(path) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Copies content of `from_dir` to `dest_dir` recursively. It is an error
// if `from_dir` is not an existing directory or if `dest_dir` exists and
// is not a directory.
[[nodiscard]] bool CopyDirectory(NSString* from_dir, NSString* dest_dir) {
  if (!DirectoryExists(from_dir)) {
    DLOG(WARNING) << "Error copying directory: "
                  << base::SysNSStringToUTF8(from_dir) << " to "
                  << base::SysNSStringToUTF8(dest_dir) << ": no such directory";
    return false;
  }

  switch (GetPathStatus(dest_dir)) {
    case PathStatus::kDirectory:
      if (!DeleteRecursively(dest_dir)) {
        return false;
      }
      break;

    case PathStatus::kFile:
      DLOG(WARNING) << "Error copying directory: "
                    << base::SysNSStringToUTF8(from_dir) << " to "
                    << base::SysNSStringToUTF8(dest_dir) << ": file exists";
      return false;

    case PathStatus::kInexistent:
      break;
  }

  // Create parent directory of `dest_dir`.
  if (!CreateDirectory([dest_dir stringByDeletingLastPathComponent])) {
    return false;
  }

  // Use hardlink to perform the copy to reduce the impact on storage. The
  // documentation of -linkItemAtPath:toPath:error: explicitly explain that
  // if source is a directory, the method create the destination directory
  // and hard-link the content recursively.

  NSError* error = nil;
  if (![[NSFileManager defaultManager] linkItemAtPath:from_dir
                                               toPath:dest_dir
                                                error:&error]) {
    DLOG(WARNING) << "Error copying directory: "
                  << base::SysNSStringToUTF8(from_dir) << " to "
                  << base::SysNSStringToUTF8(dest_dir) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Copies file at `from_path` to `dest_path`. It is an error if `from_path`
// is not a file or if `dest_path` exists and is not a file.
[[nodiscard]] bool CopyFile(NSString* from_path, NSString* dest_path) {
  if (!FileExists(from_path)) {
    DLOG(WARNING) << "Error copying file: "
                  << base::SysNSStringToUTF8(from_path) << " to "
                  << base::SysNSStringToUTF8(dest_path) << ": no such file";
    return false;
  }

  switch (GetPathStatus(dest_path)) {
    case PathStatus::kDirectory:
      DLOG(WARNING) << "Error copying file: "
                    << base::SysNSStringToUTF8(from_path) << " to "
                    << base::SysNSStringToUTF8(dest_path)
                    << ": directory exists";
      break;

    case PathStatus::kFile:
      if (!DeleteRecursively(dest_path)) {
        return false;
      }
      break;

    case PathStatus::kInexistent:
      break;
  }

  // Create parent directory of `dest_path`.
  if (!CreateDirectory([dest_path stringByDeletingLastPathComponent])) {
    return false;
  }

  // Use hardlink to perform the copy to reduce the impact on storage.

  NSError* error = nil;
  if (![[NSFileManager defaultManager] linkItemAtPath:from_path
                                               toPath:dest_path
                                                error:&error]) {
    DLOG(WARNING) << "Error copying file: "
                  << base::SysNSStringToUTF8(from_path) << " to "
                  << base::SysNSStringToUTF8(dest_path) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Writes `data` to `filename` and returns whether the operation was a success.
// The file is created with protection until first user authentication.
[[nodiscard]] bool WriteFile(NSString* filename, NSData* data) {
  if (!CreateDirectory([filename stringByDeletingLastPathComponent])) {
    return false;
  }

  // Options for writing data.
  constexpr NSDataWritingOptions options =
      NSDataWritingAtomic |
      NSDataWritingFileProtectionCompleteUntilFirstUserAuthentication;

  NSError* error = nil;
  if (![data writeToFile:filename options:options error:&error]) {
    DLOG(WARNING) << "Error writing to file: "
                  << base::SysNSStringToUTF8(filename) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return false;
  }

  return true;
}

// Reads content of `filename` and returns it as a `NSData*` or nil on error.
[[nodiscard]] NSData* ReadFile(NSString* filename) {
  NSError* error = nil;
  NSData* data = [NSData dataWithContentsOfFile:filename
                                        options:0
                                          error:&error];
  if (!data) {
    DLOG(WARNING) << "Error loading from file: "
                  << base::SysNSStringToUTF8(filename) << ": "
                  << base::SysNSStringToUTF8([error description]);
    return nil;
  }

  return data;
}

}  // namespace internal

bool FileExists(const base::FilePath& filename) {
  return internal::FileExists(base::apple::FilePathToNSString(filename));
}

bool DirectoryExists(const base::FilePath& dirname) {
  return internal::DirectoryExists(base::apple::FilePathToNSString(dirname));
}

bool RenameFile(const base::FilePath& from, const base::FilePath& dest) {
  return internal::RenameFile(base::apple::FilePathToNSString(from),
                              base::apple::FilePathToNSString(dest));
}

bool CreateDirectory(const base::FilePath& directory) {
  return internal::CreateDirectory(base::apple::FilePathToNSString(directory));
}

bool DirectoryEmpty(const base::FilePath& directory) {
  return internal::DirectoryEmpty(base::apple::FilePathToNSString(directory));
}

bool DeleteRecursively(const base::FilePath& path) {
  return internal::DeleteRecursively(base::apple::FilePathToNSString(path));
}

bool CopyDirectory(const base::FilePath& from_dir,
                   const base::FilePath& dest_dir) {
  return internal::CopyDirectory(base::apple::FilePathToNSString(from_dir),
                                 base::apple::FilePathToNSString(dest_dir));
}

bool CopyFile(const base::FilePath& from_path,
              const base::FilePath& dest_path) {
  return internal::CopyFile(base::apple::FilePathToNSString(from_path),
                            base::apple::FilePathToNSString(dest_path));
}

bool WriteFile(const base::FilePath& filename, NSData* data) {
  return internal::WriteFile(base::apple::FilePathToNSString(filename), data);
}

NSData* ReadFile(const base::FilePath& filename) {
  return internal::ReadFile(base::apple::FilePathToNSString(filename));
}

bool WriteProto(const base::FilePath& filename,
                const google::protobuf::MessageLite& proto) {
  @autoreleasepool {
    // Allocate a NSData object large enough to hold the serialized protobuf.
    const size_t serialized_size = proto.ByteSizeLong();
    NSMutableData* data = [NSMutableData dataWithLength:serialized_size];

    if (!proto.SerializeToArray(data.mutableBytes, data.length)) {
      DLOG(WARNING) << "Error serializing proto to file: "
                    << filename.AsUTF8Unsafe();
      return false;
    }

    return WriteFile(filename, data);
  }
}

bool ParseProto(const base::FilePath& filename,
                google::protobuf::MessageLite& proto) {
  NSData* data = ReadFile(filename);
  if (!data) {
    return false;
  }

  if (!proto.ParseFromArray(data.bytes, data.length)) {
    DLOG(WARNING) << "Error parsing proto from file: "
                  << filename.AsUTF8Unsafe();
    return false;
  }

  return true;
}

NSData* ArchiveRootObject(NSObject<NSCoding>* object) {
  NSError* error = nil;
  NSData* archived = [NSKeyedArchiver archivedDataWithRootObject:object
                                           requiringSecureCoding:NO
                                                           error:&error];

  if (error) {
    DLOG(WARNING) << "Error serializing data: "
                  << base::SysNSStringToUTF8([error description]);
    return nil;
  }

  return archived;
}

NSObject<NSCoding>* DecodeRootObject(NSData* data) {
  NSError* error = nil;
  NSKeyedUnarchiver* unarchiver =
      [[NSKeyedUnarchiver alloc] initForReadingFromData:data error:&error];
  if (error) {
    DLOG(WARNING) << "Error deserializing data: "
                  << base::SysNSStringToUTF8([error description]);
    return nil;
  }

  unarchiver.requiresSecureCoding = NO;

  // -decodeObjectForKey: propagates exception, so wrap the call in
  // @try/@catch block to prevent them from terminating the app.
  @try {
    return [unarchiver decodeObjectForKey:@"root"];
  } @catch (NSException* exception) {
    DLOG(WARNING) << "Error deserializing data: "
                  << base::SysNSStringToUTF8([exception description]);
    return nil;
  }
}

SessionWindowIOS* ReadSessionWindow(const base::FilePath& filename) {
  NSData* data = ReadFile(filename);
  if (!data) {
    return nil;
  }

  NSObject* root = DecodeRootObject(data);
  if (!root) {
    return nil;
  }

  if ([root isKindOfClass:[SessionIOS class]]) {
    SessionIOS* session = base::apple::ObjCCastStrict<SessionIOS>(root);
    if (session.sessionWindows.count != 1) {
      DLOG(WARNING) << "Error deserializing data: "
                    << "not exactly one SessionWindowIOS.";
      return nil;
    }

    return session.sessionWindows[0];
  }

  return base::apple::ObjCCast<SessionWindowIOS>(root);
}

bool WriteSessionWindow(const base::FilePath& filename,
                        SessionWindowIOS* session) {
  NSData* data = ArchiveRootObject(session);
  if (!data) {
    return false;
  }

  return WriteFile(filename, data);
}

}  // namespace ios::sessions