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

#include "chrome/installer/gcapi_mac/gcapi.h"

#import <Cocoa/Cocoa.h>
#include <grp.h>
#include <pwd.h>
#include <stddef.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/utsname.h>

#include "base/compiler_specific.h"

namespace {

// The "~~" prefixes are replaced with the home directory of the
// console owner (i.e. not the home directory of the euid).
NSString* const kChromeInstallPath = @"/Applications/Google Chrome.app";

NSString* const kBrandKey = @"KSBrandID";
NSString* const kUserBrandPath = @"~~/Library/Google/Google Chrome Brand.plist";

// ksadmin moved from MacOS to Helpers in Keystone 1.2.13.112, 2019-11-12. A
// symbolic link from the old location was left in place, but may not remain
// indefinitely. Try the new location first, falling back to the old if needed.
NSString* const kSystemKsadminPath =
    @"/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/"
     "Contents/Helpers/ksadmin";
NSString* const kSystemKsadminPathOld =
    @"/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/"
     "Contents/MacOS/ksadmin";
NSString* const kUserKsadminPath =
    @"~~/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/"
     "Contents/Helpers/ksadmin";
NSString* const kUserKsadminPathOld =
    @"~~/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/"
     "Contents/MacOS/ksadmin";

NSString* const kSystemMasterPrefsPath =
    @"/Library/Google/Google Chrome Master Preferences";
NSString* const kUserMasterPrefsPath =
    @"~~/Library/Application Support/Google/Chrome/"
     "Google Chrome Master Preferences";

// Condensed from chromium's base/mac/mac_util.mm.
bool IsMacOSVersionSupported() {
  // base::OperatingSystemVersionNumbers() at one time called Gestalt(), which
  // was observed to be able to spawn threads (see https://crbug.com/41201866).
  // Nowadays that function calls -[NSProcessInfo operatingSystemVersion], whose
  // current implementation does things like hit the file system, which is
  // possibly a blocking operation. Either way, it's overkill for what needs to
  // be done here.
  //
  // uname, on the other hand, is implemented as a simple series of sysctl
  // system calls to obtain the relevant data from the kernel. The data is
  // compiled right into the kernel, so no threads or blocking or other
  // funny business is necessary.

  struct utsname uname_info;
  if (uname(&uname_info) != 0) {
    return false;
  }
  if (UNSAFE_TODO(strcmp(uname_info.sysname, "Darwin")) != 0) {
    return false;
  }

  char* dot = UNSAFE_TODO(strchr(uname_info.release, '.'));
  if (!dot) {
    return false;
  }

  int darwin_major_version = atoi(uname_info.release);
  if (darwin_major_version < 6) {
    return false;
  }

  int macos_version;
  // Darwin major versions 6 through 19 corresponded to macOS versions 10.2
  // through 10.15. Darwin major version 20 corresponds to macOS version 11.0.
  // Assume a correspondence between Darwin's major version numbers and macOS
  // major version numbers.
  if (darwin_major_version <= 19) {
    macos_version = 1000 + darwin_major_version - 4;
  } else {
    macos_version = 100 * (darwin_major_version - 9);
  }

  // Chrome is known to work on 11.0 - 15.x.
  return macos_version >= 1100 && macos_version < 1600;
}

// Returns the pid/gid of the logged-in user, even if getuid() claims that the
// current user is root.
// Returns nullptr on error.
passwd* GetRealUserId() {
  CFDictionaryRef session_info = CGSessionCopyCurrentDictionary();
  CFAutorelease(session_info);
  if (!session_info) {
    return nullptr;  // Possibly no screen plugged in.
  }

  CFNumberRef ns_uid =
      (CFNumberRef)CFDictionaryGetValue(session_info, kCGSessionUserIDKey);
  if (CFGetTypeID(ns_uid) != CFNumberGetTypeID()) {
    return nullptr;
  }

  uid_t uid;
  BOOL success = CFNumberGetValue(ns_uid, kCFNumberSInt32Type, &uid);
  if (!success) {
    return nullptr;
  }

  return getpwuid(uid);
}

enum TicketKind { kSystemTicket, kUserTicket };

// Replaces "~~" with |home_dir|.
NSString* AdjustHomedir(NSString* s, const char* home_dir) {
  if (![s hasPrefix:@"~~"]) {
    return s;
  }
  NSString* ns_home_dir = @(home_dir);
  return [ns_home_dir stringByAppendingString:[s substringFromIndex:2]];
}

// If |chrome_path| is not 0, |*chrome_path| is set to the path where chrome
// is according to keystone. It's only set if that path exists on disk.
BOOL FindChromeTicket(TicketKind kind,
                      const passwd* user,
                      NSString** chrome_path) {
  if (chrome_path) {
    *chrome_path = nil;
  }

  NSMutableArray<NSString*>* keystone_paths = [NSMutableArray
      arrayWithObjects:kSystemKsadminPath, kSystemKsadminPathOld, nil];
  if (kind == kUserTicket) {
    [keystone_paths insertObject:AdjustHomedir(kUserKsadminPath, user->pw_dir)
                         atIndex:0];
    [keystone_paths
        insertObject:AdjustHomedir(kUserKsadminPathOld, user->pw_dir)
             atIndex:1];
  }

  for (NSString* path in keystone_paths) {
    if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
      continue;
    }

    NSString* string = nil;
    bool ksadmin_ran_successfully = false;

    @try {
      NSTask* task = [[NSTask alloc] init];
      task.launchPath = path;

      NSArray* arguments = @[
        kind == kUserTicket ? @"--user-store" : @"--system-store",
        @"--print-tickets",
        @"--productid",
        @"com.google.Chrome",
      ];
      if (geteuid() == 0 && kind == kUserTicket) {
        NSString* run_as = @(user->pw_name);
        task.launchPath = @"/usr/bin/sudo";
        arguments =
            [@[ @"-u", run_as, path ] arrayByAddingObjectsFromArray:arguments];
      }
      task.arguments = arguments;

      NSPipe* pipe = [NSPipe pipe];
      task.standardOutput = pipe;

      NSFileHandle* file = pipe.fileHandleForReading;

      [task launch];

      NSData* data = [file readDataToEndOfFile];
      [task waitUntilExit];

      ksadmin_ran_successfully = task.terminationStatus == 0;
      string = [[NSString alloc] initWithData:data
                                     encoding:NSUTF8StringEncoding];
    } @catch (id exception) {
      // Most likely, ks_path didn't exist.
    }

    if (ksadmin_ran_successfully && string.length > 0) {
      // If the user deleted chrome, it doesn't get unregistered in keystone.
      // Check if the path keystone thinks chrome is at still exists, and if not
      // treat this as "chrome isn't installed". Sniff for
      //   xc=<KSPathExistenceChecker:1234 path=/Applications/Google Chrome.app>
      // in the output. But don't mess with system tickets, since reinstalling
      // a user chrome on top of a system ticket produces a non-autoupdating
      // chrome.
      NSRange start = [string rangeOfString:@"\n\txc=<KSPathExistenceChecker:"];
      if (start.location == NSNotFound && start.length == 0) {
        return YES;  // Err on the cautious side.
      }
      string = [string substringFromIndex:start.location];

      start = [string rangeOfString:@"path="];
      if (start.location == NSNotFound && start.length == 0) {
        return YES;  // Err on the cautious side.
      }
      string = [string substringFromIndex:start.location];

      NSRange end = [string rangeOfString:@".app>\n\t"];
      if (end.location == NSNotFound && end.length == 0) {
        return YES;
      }

      string = [string substringToIndex:NSMaxRange(end) - [@">\n\t" length]];
      string = [string substringFromIndex:start.length];

      BOOL exists = [NSFileManager.defaultManager fileExistsAtPath:string];
      if (exists && chrome_path) {
        *chrome_path = string;
      }
      // Don't allow reinstallation over a system ticket, even if chrome doesn't
      // exist on disk.
      if (kind == kSystemTicket) {
        return YES;
      }
      return exists;
    }
  }

  return NO;
}

// File permission mask for files created by gcapi.
const mode_t kUserPermissions = 0755;
const mode_t kAdminPermissions = 0775;

BOOL CreatePathToFile(NSString* path, const passwd* user) {
  path = [path stringByDeletingLastPathComponent];

  // Default owner, group, permissions:
  // * Permissions are set according to the umask of the current process. For
  //   more information, see umask.
  // * The owner ID is set to the effective user ID of the process.
  // * The group ID is set to that of the parent directory.
  // The default group ID is fine. Owner ID is fine if creating a system path,
  // but when creating a user path explicitly set the owner in case euid is 0.
  // Do set permissions explicitly; for admin paths all admins can write, for
  // user paths just the owner may.
  NSMutableDictionary* attributes = [NSMutableDictionary dictionary];
  if (user) {
    attributes[NSFilePosixPermissions] = @(kUserPermissions);
    attributes[NSFileOwnerAccountID] = @(user->pw_uid);
  } else {
    attributes[NSFilePosixPermissions] = @(kAdminPermissions);
    attributes[NSFileGroupOwnerAccountName] = @"admin";
  }

  return [NSFileManager.defaultManager createDirectoryAtPath:path
                                 withIntermediateDirectories:YES
                                                  attributes:attributes
                                                       error:nil];
}

// Tries to write |data| at |user_path|.
// Returns the path where it wrote, or nil on failure.
NSString* WriteUserData(NSData* data, NSString* user_path, const passwd* user) {
  user_path = AdjustHomedir(user_path, user->pw_dir);
  if (CreatePathToFile(user_path, user) && [data writeToFile:user_path
                                                  atomically:YES]) {
    chmod(user_path.fileSystemRepresentation, kUserPermissions & ~0111);
    chown(user_path.fileSystemRepresentation, user->pw_uid, user->pw_gid);
    return user_path;
  }
  return nil;
}

// Tries to write |data| at |system_path| or if that fails at |user_path|.
// Returns the path where it wrote, or nil on failure.
NSString* WriteData(NSData* data,
                    NSString* system_path,
                    NSString* user_path,
                    const passwd* user) {
  // Try system first.
  if (CreatePathToFile(system_path, nullptr) && [data writeToFile:system_path
                                                       atomically:YES]) {
    chmod(system_path.fileSystemRepresentation, kAdminPermissions & ~0111);
    // Make sure the file is owned by group admin.
    if (group* group = getgrnam("admin")) {
      chown(system_path.fileSystemRepresentation, 0, group->gr_gid);
    }
    return system_path;
  }

  // Failed, try user.
  return WriteUserData(data, user_path, user);
}

NSString* WriteBrandCode(const char* brand_code, const passwd* user) {
  NSDictionary* brand_dict = @{
    kBrandKey : @(brand_code),
  };
  NSData* contents = [NSPropertyListSerialization
      dataWithPropertyList:brand_dict
                    format:NSPropertyListBinaryFormat_v1_0
                   options:0
                     error:nil];

  return WriteUserData(contents, kUserBrandPath, user);
}

BOOL WriteMasterPrefs(const char* master_prefs_contents,
                      size_t master_prefs_contents_size,
                      const passwd* user) {
  NSData* contents = [NSData dataWithBytes:master_prefs_contents
                                    length:master_prefs_contents_size];
  return WriteData(contents, kSystemMasterPrefsPath, kUserMasterPrefsPath,
                   user) != nil;
}

NSString* PathToFramework(NSString* app_path, NSDictionary* info_plist) {
  NSString* version = info_plist[@"CFBundleShortVersionString"];
  if (!version) {
    return nil;
  }
  return [NSString pathWithComponents:@[
    app_path, @"Contents", @"Frameworks", @"Google Chrome Framework.framework",
    @"Versions", version
  ]];
}

NSString* PathToInstallScript(NSString* app_path, NSDictionary* info_plist) {
  return [PathToFramework(app_path, info_plist)
      stringByAppendingPathComponent:@"Resources/install.sh"];
}

bool isbrandchar(int c) {
  // Always four upper-case alpha chars.
  return c >= 'A' && c <= 'Z';
}

}  // namespace

int GoogleChromeCompatibilityCheck(unsigned* reasons) {
  unsigned local_reasons = 0;
  @autoreleasepool {
    if (!IsMacOSVersionSupported()) {
      local_reasons |= GCCC_ERROR_OSNOTSUPPORTED;
    }

    NSString* path;
    if (FindChromeTicket(kSystemTicket, nullptr, &path)) {
      local_reasons |= GCCC_ERROR_ALREADYPRESENT;
      if (!path) {  // Ticket points to nothingness.
        local_reasons |= GCCC_ERROR_ACCESSDENIED;
      }
    }

    passwd* user = GetRealUserId();
    if (!user) {
      local_reasons |= GCCC_ERROR_ACCESSDENIED;
    } else if (FindChromeTicket(kUserTicket, user, nullptr)) {
      local_reasons |= GCCC_ERROR_ALREADYPRESENT;
    }

    if ([NSFileManager.defaultManager fileExistsAtPath:kChromeInstallPath]) {
      local_reasons |= GCCC_ERROR_ALREADYPRESENT;
    }

    if ((local_reasons & GCCC_ERROR_ALREADYPRESENT) == 0) {
      if (![NSFileManager.defaultManager
              isWritableFileAtPath:@"/Applications"]) {
        local_reasons |= GCCC_ERROR_ACCESSDENIED;
      }
    }
  }

  if (reasons != nullptr) {
    *reasons = local_reasons;
  }
  return local_reasons == 0;
}

int InstallGoogleChrome(const char* source_path,
                        const char* brand_code,
                        const char* master_prefs_contents,
                        unsigned master_prefs_contents_size) {
  if (!GoogleChromeCompatibilityCheck(nullptr)) {
    return 0;
  }

  @autoreleasepool {
    passwd* user = GetRealUserId();
    if (!user) {
      return 0;
    }

    NSString* app_path = @(source_path);
    NSString* info_plist_path =
        [app_path stringByAppendingPathComponent:@"Contents/Info.plist"];
    NSDictionary* info_plist =
        [NSDictionary dictionaryWithContentsOfFile:info_plist_path];

    // Use install.sh from the Chrome app bundle to copy Chrome to its
    // destination.
    NSString* install_script = PathToInstallScript(app_path, info_plist);
    if (!install_script) {
      return 0;
    }

    @try {
      NSTask* task = [[NSTask alloc] init];

      // install.sh tries to make the installed app admin-writable, but
      // only when it's not run as root.
      if (geteuid() == 0) {
        // Use |su $(whoami)| instead of sudo -u. If the current user is in more
        // than 16 groups, |sudo -u $(whoami)| will drop all but the first 16
        // groups, which can lead to problems (e.g. if "admin" is one of the
        // dropped groups).
        // Since geteuid() is 0, su won't prompt for a password.
        NSString* run_as = @(user->pw_name);
        task.launchPath = @"/usr/bin/su";

        NSString* single_quote_escape = @"'\"'\"'";
        NSString* install_script_quoted = [install_script
            stringByReplacingOccurrencesOfString:@"'"
                                      withString:single_quote_escape];
        NSString* app_path_quoted =
            [app_path stringByReplacingOccurrencesOfString:@"'"
                                                withString:single_quote_escape];
        NSString* install_path_quoted = [kChromeInstallPath
            stringByReplacingOccurrencesOfString:@"'"
                                      withString:single_quote_escape];

        NSString* install_script_execution = [NSString
            stringWithFormat:@"exec '%@' '%@' '%@'", install_script_quoted,
                             app_path_quoted, install_path_quoted];
        task.arguments = @[ run_as, @"-c", install_script_execution ];
      } else {
        task.launchPath = install_script;
        task.arguments = @[ app_path, kChromeInstallPath ];
      }

      [task launch];
      [task waitUntilExit];
      if (task.terminationStatus != 0) {
        return 0;
      }
    } @catch (id exception) {
      return 0;
    }

    // Set brand code. If Chrome's Info.plist contains a brand code, use that.
    NSString* info_plist_brand = info_plist[kBrandKey];
    if (info_plist_brand &&
        [info_plist_brand respondsToSelector:@selector(UTF8String)]) {
      brand_code = [info_plist_brand UTF8String];
    }

    BOOL valid_brand_code =
        UNSAFE_TODO(brand_code && strlen(brand_code) == 4 &&
                    isbrandchar(brand_code[0]) && isbrandchar(brand_code[1]) &&
                    isbrandchar(brand_code[2]) && isbrandchar(brand_code[3]));

    if (valid_brand_code) {
      WriteBrandCode(brand_code, user);
    }

    // Write master prefs.
    if (master_prefs_contents) {
      WriteMasterPrefs(master_prefs_contents, master_prefs_contents_size, user);
    }

    // TODO Set default browser if requested.
  }
  return 1;
}

int LaunchGoogleChrome() {
  @autoreleasepool {
    passwd* user = GetRealUserId();
    if (!user) {
      return 0;
    }

    NSString* app_path;

    NSString* path;
    if (FindChromeTicket(kUserTicket, user, &path) && path) {
      app_path = path;
    } else if (FindChromeTicket(kSystemTicket, nullptr, &path) && path) {
      app_path = path;
    } else {
      app_path = kChromeInstallPath;
    }

    // NSWorkspace launches processes as the current console owner,
    // even when running with euid of 0.
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    if (!semaphore) {
      return 0;
    }

    __block bool success = false;

    [NSWorkspace.sharedWorkspace
        openApplicationAtURL:[NSURL fileURLWithPath:app_path]
               configuration:[NSWorkspaceOpenConfiguration configuration]
           completionHandler:^(NSRunningApplication* app, NSError* error) {
             success = (error == nil);
             dispatch_semaphore_signal(semaphore);
           }];

    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    return success ? 1 : 0;
  }
}
