// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// 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 <sys/stat.h>
#include <sys/types.h>
#include <sys/utsname.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";

NSString* const kSystemKsadminPath =
    @"/Library/Google/GoogleSoftwareUpdate/GoogleSoftwareUpdate.bundle/"
     "Contents/MacOS/ksadmin";

NSString* const kUserKsadminPath =
    @"~~/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 IsOSXVersionSupported() {
  // On 10.6, Gestalt() was observed to be able to spawn threads (see
  // http://crbug.com/53200). Don't call Gestalt().
  struct utsname uname_info;
  if (uname(&uname_info) != 0)
    return false;
  if (strcmp(uname_info.sysname, "Darwin") != 0)
    return false;

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

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

  // The Darwin major version is always 4 greater than the Mac OS X minor
  // version for Darwin versions beginning with 6, corresponding to Mac OS X
  // 10.2.
  int mac_os_x_minor_version = darwin_major_version - 4;

  // Chrome is known to work on 10.6 - 10.9.
  return mac_os_x_minor_version >= 6 && mac_os_x_minor_version <= 9;
}

// Returns the pid/gid of the logged-in user, even if getuid() claims that the
// current user is root.
// Returns NULL on error.
passwd* GetRealUserId() {
  CFDictionaryRef session_info_dict = CGSessionCopyCurrentDictionary();
  [NSMakeCollectable(session_info_dict) autorelease];
  if (!session_info_dict)
    return NULL;  // Possibly no screen plugged in.

  CFNumberRef ns_uid = (CFNumberRef)CFDictionaryGetValue(session_info_dict,
                                                         kCGSessionUserIDKey);
  if (CFGetTypeID(ns_uid) != CFNumberGetTypeID())
    return NULL;

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

  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 = [NSString stringWithUTF8String: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;

  // Don't use Objective-C 2 loop syntax, in case an installer runs on 10.4.
  NSMutableArray* keystone_paths =
      [NSMutableArray arrayWithObject:kSystemKsadminPath];
  if (kind == kUserTicket) {
    [keystone_paths insertObject:AdjustHomedir(kUserKsadminPath, user->pw_dir)
                        atIndex:0];
  }
  NSEnumerator* e = [keystone_paths objectEnumerator];
  id ks_path;
  while ((ks_path = [e nextObject])) {
    if (![[NSFileManager defaultManager] fileExistsAtPath:ks_path])
      continue;

    NSTask* task = nil;
    NSString* string = nil;
    bool ksadmin_ran_successfully = false;

    @try {
      task = [[NSTask alloc] init];
      [task setLaunchPath:ks_path];

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

      NSPipe* pipe = [NSPipe pipe];
      [task setStandardOutput: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] autorelease];
    }
    @catch (id exception) {
      // Most likely, ks_path didn't exist.
    }
    [task release];

    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 setObject:[NSNumber numberWithShort:kUserPermissions]
                   forKey:NSFilePosixPermissions];
    [attributes setObject:[NSNumber numberWithInt:user->pw_uid]
                   forKey:NSFileOwnerAccountID];
  } else {
    [attributes setObject:[NSNumber numberWithShort:kAdminPermissions]
                   forKey:NSFilePosixPermissions];
    [attributes setObject:@"admin" forKey:NSFileGroupOwnerAccountName];
  }

  NSFileManager* manager = [NSFileManager defaultManager];
  return [manager 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, NULL) &&
      [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: [NSString stringWithUTF8String:brand_code],
  };
  NSData* contents = [NSPropertyListSerialization
      dataFromPropertyList:brand_dict
                    format:NSPropertyListBinaryFormat_v1_0
          errorDescription: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 objectForKey:@"CFBundleShortVersionString"];
  if (!version)
    return nil;
  return [[[app_path
      stringByAppendingPathComponent:@"Contents/Versions"]
      stringByAppendingPathComponent:version]
      stringByAppendingPathComponent:@"Google Chrome Framework.framework"];
}

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 {
    passwd* user = GetRealUserId();
    if (!user)
      return GCCC_ERROR_ACCESSDENIED;

    if (!IsOSXVersionSupported())
      local_reasons |= GCCC_ERROR_OSNOTSUPPORTED;

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

    if (FindChromeTicket(kUserTicket, user, NULL))
      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 != NULL)
    *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(NULL))
    return 0;

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

    NSString* app_path = [NSString stringWithUTF8String: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] autorelease];

      // 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 = [NSString stringWithUTF8String:user->pw_name];
        [task setLaunchPath:@"/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 setArguments:
            @[run_as, @"-c", install_script_execution]];
      } else {
        [task setLaunchPath:install_script];
        [task setArguments:@[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 objectForKey:kBrandKey];
    if (info_plist_brand &&
        [info_plist_brand respondsToSelector:@selector(UTF8String)])
      brand_code = [info_plist_brand UTF8String];

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

    NSString* brand_path = nil;
    if (valid_brand_code)
      brand_path = 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, NULL, &path) && path)
      app_path = path;
    else
      app_path = kChromeInstallPath;

    // NSWorkspace launches processes as the current console owner,
    // even when running with euid of 0.
    return [[NSWorkspace sharedWorkspace] launchApplication:app_path];
  }
}