chromium/chrome/updater/mac/client_lib/CRURegistration.m

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

#import "CRURegistration.h"

#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>

#import "CRURegistration-Private.h"
#include "chrome/updater/updater_branding.h"

#pragma mark - Constants

NSString* const CRURegistrationErrorDomain = @"org.chromium.CRURegistration";
NSString* const CRUReturnCodeErrorDomain = @"org.chromium.CRUReturnCode";
NSString* const CRURegistrationInternalErrorDomain =
    @"org.chromium.CRURegistrationInternal";

// Keys that may be present in NSError `userInfo` dictionaries.
NSString* const CRUErrnoKey = @"org.chromium.CRUErrno";
NSString* const CRUStdStreamNameKey = @"org.chromium.CRUStdStreamName";
NSString* const CRUStderrKey = @"org.chromium.CRUStderr";
NSString* const CRUStdoutKey = @"org.chromium.CRUStdout";
NSString* const CRUReturnCodeKey = @"org.chromium.CRUReturnCode";

#pragma mark - CRUAsyncTaskRunner

@implementation CRUAsyncTaskRunner {
  // These fields are written once during init and never again.
  dispatch_queue_t _parentQueue;
  dispatch_queue_t _privateQueue;
  NSTask* _task;

  // These fields are guarded by `_privateQueue`.
  BOOL _launched;
  NSMutableData* _taskStdoutData;
  NSMutableData* _taskStderrData;
  NSError* _taskManagementError;
  NSPipe* _taskStdoutPipe;
  NSPipe* _taskStderrPipe;
  dispatch_group_t _done_group;
}

- (instancetype)initWithTask:(NSTask*)task
                 targetQueue:(dispatch_queue_t)targetQueue {
  if (self = [super init]) {
    _task = task;
    _parentQueue = targetQueue;
    _privateQueue = dispatch_queue_create_with_target(
        "CRUAsyncTaskRunner", DISPATCH_QUEUE_SERIAL, targetQueue);
  }
  return self;
}

- (void)launchWithReply:(CRUTaskResultCallback)reply {
  dispatch_async(_privateQueue, ^{
    [self syncLaunchWithReply:reply];
  });
}

- (void)syncLaunchWithReply:(CRUTaskResultCallback)reply {
  if (_launched) {
    NSString* taskUrl = _task.executableURL.description;
    NSString* argList = [_task.arguments componentsJoinedByString:@"\n"];
    dispatch_async(_parentQueue, ^{
      reply(nil, nil,
            [NSError
                errorWithDomain:CRURegistrationInternalErrorDomain
                           code:CRURegistrationInternalErrorTaskAlreadyLaunched
                       userInfo:@{
                         NSFilePathErrorKey : taskUrl,
                         NSDebugDescriptionErrorKey : argList,
                       }]);
    });
    return;
  }

  _taskStdoutPipe = [NSPipe pipe];
  _taskStderrPipe = [NSPipe pipe];
  _task.standardOutput = _taskStdoutPipe;
  _task.standardError = _taskStderrPipe;
  _taskStdoutData = [NSMutableData data];
  _taskStderrData = [NSMutableData data];
  _taskManagementError = nil;

  _done_group = dispatch_group_create();
  // Enter for task configuration, to avoid invoking the handler block if the
  // task exits before we've gotten a chance to start processing its output.
  dispatch_group_enter(_done_group);

  dispatch_group_notify(_done_group, _privateQueue, ^{
    self->_task.terminationHandler = nil;
    if (self->_taskManagementError) {
      // The task never launched; touching task.terminationStatus would crash.
      dispatch_async(self->_parentQueue, ^{
        reply(nil, nil, self->_taskManagementError);
      });
      return;
    }

    NSError* returnCodeError = nil;
    if (self->_task.terminationStatus) {
      returnCodeError = [NSError errorWithDomain:CRUReturnCodeErrorDomain
                                            code:self->_task.terminationStatus
                                        userInfo:nil];
    }
    dispatch_async(self->_parentQueue, ^{
      reply([[NSString alloc] initWithData:self->_taskStdoutData
                                  encoding:NSUTF8StringEncoding],
            [[NSString alloc] initWithData:self->_taskStderrData
                                  encoding:NSUTF8StringEncoding],
            returnCodeError);
    });
  });

  // All fields are prepared and the result callback is armed. Hand off to
  // `syncFinishLaunching` so we can early-out on failure without specifically
  // balancing _done_group on each exit path.
  [self syncFinishLaunching];
  dispatch_group_leave(_done_group);
}

- (void)syncFinishLaunching {
  // Local reference to avoid referring to queue-protected fields of `_self`
  // without necessarily being on `_private_queue` -- there are no guarantees
  // about where an NSTask's termination handler is executed.
  dispatch_group_t done_group = _done_group;
  // Enter `_done_group` for task execution itself.
  dispatch_group_enter(done_group);
  _task.terminationHandler = ^(NSTask* unused) {
    dispatch_group_leave(done_group);
  };

  NSError* launchError = nil;
  if (![_task launchAndReturnError:&launchError]) {
    _taskManagementError = launchError;
    // Cancel the `enter`, since the termination handler will never run.
    dispatch_group_leave(done_group);
    return;
  }

  // Task is launched, kick off async I/O.

  [self syncSubscribeAsyncOnHandle:_taskStdoutPipe.fileHandleForReading
                              into:_taskStdoutData
                             named:@"stdout"];
  [self syncSubscribeAsyncOnHandle:_taskStderrPipe.fileHandleForReading
                              into:_taskStderrData
                             named:@"stderr"];
}

- (void)syncSubscribeAsyncOnHandle:(NSFileHandle*)readHandle
                              into:(NSMutableData*)dataOut
                             named:(NSString*)streamName {
  dispatch_group_enter(_done_group);
  dispatch_io_t stdoutIO = dispatch_io_create(
      DISPATCH_IO_STREAM, readHandle.fileDescriptor, _privateQueue,
      ^(int unused) {
        NSError* cleanupError = nil;
        NSAssert([readHandle closeAndReturnError:&cleanupError],
                 @"couldn't close task %@: %@", streamName, cleanupError);
      });
  dispatch_io_read(
      stdoutIO, 0, SIZE_MAX, _privateQueue,
      ^(bool done, dispatch_data_t chunk, int error) {
        if (chunk) {
          // dispatch_data_t may be cast to NSData in 64-bit software:
          // https://developer.apple.com/documentation/dispatch/dispatch_data_t?language=objc
          [dataOut appendData:(NSData*)chunk];
        }
        if (done || error) {
          dispatch_io_close(stdoutIO, 0);
          if (error && !self->_taskManagementError) {
            self->_taskManagementError = [NSError
                errorWithDomain:CRURegistrationErrorDomain
                           code:CRURegistrationErrorTaskStreamUnreadable
                       userInfo:@{
                         CRUErrnoKey : @(error),
                         CRUStdStreamNameKey : streamName,
                       }];
          }
          dispatch_group_leave(self->_done_group);
        }
      });
}

@end  // CRUAsyncTaskRunner

#pragma mark - CRURegistrationWorkItem

@implementation CRURegistrationWorkItem

@synthesize binPathCallback = _binPathCallback;
@synthesize args = _args;
@synthesize resultCallback = _resultCallback;

@end

#pragma mark - CRURegistration

@implementation CRURegistration {
  // Immutable fields.
  NSString* _appId;
  NSString* _existenceCheckerPath;

  dispatch_queue_t _privateQueue;
  dispatch_queue_t _parentQueue;

  NSMutableArray<CRURegistrationWorkItem*>* _pendingWork;
  CRUAsyncTaskRunner* _currentWork;
}

- (instancetype)initWithAppId:(NSString*)appId
         existenceCheckerPath:(NSString*)xcPath
                  targetQueue:(dispatch_queue_t)targetQueue {
  if (self = [super init]) {
    _appId = appId;
    _existenceCheckerPath = xcPath;
    _parentQueue = targetQueue;
    _privateQueue = dispatch_queue_create_with_target(
        "CRURegistration", DISPATCH_QUEUE_SERIAL, targetQueue);
    _pendingWork = [NSMutableArray array];
  }
  return self;
}

- (instancetype)initWithAppId:(NSString*)appId
         existenceCheckerPath:(NSString*)xcPath
                          qos:(dispatch_qos_class_t)qos {
  return [self initWithAppId:appId
        existenceCheckerPath:xcPath
                 targetQueue:dispatch_get_global_queue(qos, 0)];
}

- (instancetype)initWithAppId:(NSString*)appId
         existenceCheckerPath:(NSString*)xcPath {
  return [self initWithAppId:appId
        existenceCheckerPath:xcPath
                         qos:QOS_CLASS_UTILITY];
}

/**
 * newKSAdminItem constructs a CRURegistrationWorkItem that will invoke ksadmin.
 */
- (CRURegistrationWorkItem*)newKSAdminItem {
  CRURegistrationWorkItem* ret = [[CRURegistrationWorkItem alloc] init];
  ret.binPathCallback = ^{
    return [self syncFindBestKSAdmin];
  };
  return ret;
}

- (void)fetchTagWithReply:(void (^)(NSString* _Nullable,
                                    NSError* _Nullable))reply {
  if (!reply) {
    return;
  }
  CRURegistrationWorkItem* fetchTagItem = [self newKSAdminItem];
  fetchTagItem.args = @[
    @"--print-tag",
    @"--productid",
    _appId,
    @"--xcpath",
    _existenceCheckerPath,
  ];
  fetchTagItem.resultCallback =
      ^(NSString* gotStdout, NSString* gotStderr, NSError* gotFailure) {
        if (gotFailure) {
          NSError* finalError = [self wrapError:gotFailure
                                     withStdout:gotStdout
                                      andStderr:gotStderr];
          dispatch_async(self->_parentQueue, ^{
            reply(nil, finalError);
          });
          return;
        }
        if (gotStdout.length) {
          // Trim off the trailing newline.
          NSString* tag = [gotStdout substringToIndex:gotStdout.length - 1];
          dispatch_async(self->_parentQueue, ^{
            reply(tag, nil);
          });
          return;
        }
        // Empty stdout implies "no tag".
        dispatch_async(self->_parentQueue, ^{
          reply(@"", nil);
        });
      };
  [self addWorkItems:@[ fetchTagItem ]];
}

- (void)registerVersion:(NSString*)version
                  reply:(void (^_Nullable)(NSError*))reply {
  NSAssert(version, @"nil version provided to registerVersion for app %@.",
           _appId);
  if (!version) {
    if (reply) {
      NSString* localAppId = _appId;
      dispatch_async(_parentQueue, ^{
        reply([NSError
            errorWithDomain:CRURegistrationErrorDomain
                       code:CRURegistrationErrorInvalidArgument
                   userInfo:@{
                     NSDebugDescriptionErrorKey :
                         [NSString stringWithFormat:
                                       @"CRURegistration's registerVersion for "
                                       @"app %@ was called with nil version.",
                                       localAppId],
                   }]);
      });
    }
    return;
  }

  CRURegistrationWorkItem* registerItem = [self newKSAdminItem];
  registerItem.args = @[
    @"--register",
    @"--productid",
    _appId,
    @"--version",
    version,
    @"--xcpath",
    _existenceCheckerPath,
  ];
  registerItem.resultCallback =
      ^(NSString* gotStdout, NSString* gotStderr, NSError* gotFailure) {
          if (reply) {
            dispatch_async(self->_parentQueue, ^{
              reply([self wrapError:gotFailure
                         withStdout:gotStdout
                          andStderr:gotStderr]);
            });
          }
      };

  [self addWorkItems:@[ registerItem ]];
}

- (void)installUpdaterWithReply:(void (^)(NSError* _Nullable))reply {
  CRURegistrationWorkItem* installItem = [[CRURegistrationWorkItem alloc] init];
  installItem.binPathCallback = ^{
    return [self syncBundledHelperPath];
  };
  installItem.args = @[ @"--install" ];
  installItem.resultCallback =
      ^(NSString* gotStdout, NSString* gotStderr, NSError* gotFailure) {
        if (reply) {
          dispatch_async(self->_parentQueue, ^{
            reply([self wrapError:gotFailure
                       withStdout:gotStdout
                        andStderr:gotStderr]);
          });
        }
      };
  [self addWorkItems:@[ installItem ]];
}

- (void)markActiveWithReply:(void (^)(NSError* _Nullable))reply {
  // "Mark active" doesn't use an external program, so it may run concurrently
  // with out-of-process tasks. It still uses _privateQueue so the SSD/HDD
  // access runs with the intended priority.
  dispatch_async(_privateQueue, ^{
    NSError* error;
    BOOL success = [self syncWriteActiveFileWithError:&error];
    if (!reply) {
      return;
    }
    dispatch_async(self->_parentQueue, ^{
      reply(success
                ? nil
                : [NSError errorWithDomain:CRURegistrationErrorDomain
                                      code:CRURegistrationErrorFilesystem
                                  userInfo:@{NSUnderlyingErrorKey : error}]);
    });
  });
}

#pragma mark - CRURegistration private methods

- (void)syncMaybeStartMoreWork {
  if (_currentWork || !_pendingWork.count) {
    return;
  }

  CRURegistrationWorkItem* nextItem = _pendingWork.firstObject;
  // NSMutableArray is actually a deque, so the obvious approach is performant.
  [_pendingWork removeObjectAtIndex:0];

  NSURL* taskURL = nextItem.binPathCallback();
  if (!taskURL) {
    dispatch_async(_parentQueue, ^{
      nextItem.resultCallback(
          nil, nil,
          [NSError errorWithDomain:CRURegistrationErrorDomain
                              code:CRURegistrationErrorHelperNotFound
                          userInfo:nil]);
    });
    [self syncMaybeStartMoreWork];
    return;
  }

  NSTask* task = [[NSTask alloc] init];
  task.executableURL = taskURL;
  task.arguments = nextItem.args;

  _currentWork = [[CRUAsyncTaskRunner alloc] initWithTask:task
                                              targetQueue:_privateQueue];
  [_currentWork
      launchWithReply:^(NSString* taskOut, NSString* taskErr, NSError* error) {
        self->_currentWork = nil;
        dispatch_async(self->_parentQueue, ^{
          nextItem.resultCallback(taskOut, taskErr, error);
        });
        [self syncMaybeStartMoreWork];
      }];
}

- (NSURL*)syncBundledHelperPath {
  NSURL* bundleURL = NSBundle.mainBundle.bundleURL;
  NSString* helperPathInBundle = [NSString
      stringWithFormat:@"Contents/Helpers/%1$s.app/Contents/MacOS/%1$s",
                       PRODUCT_FULLNAME_STRING];
  NSURL* helperURL = [bundleURL URLByAppendingPathComponent:helperPathInBundle
                                                isDirectory:NO];
  NSFileManager* fm = [NSFileManager defaultManager];
  if ([fm isExecutableFileAtPath:helperURL.path]) {
    return helperURL;
  }
  // Look for a test updater bundle instead.
  helperPathInBundle =
      [NSString stringWithFormat:
                    @"Contents/Helpers/%1$s_test.app/Contents/MacOS/%1$s_test",
                    PRODUCT_FULLNAME_STRING];
  helperURL = [bundleURL URLByAppendingPathComponent:helperPathInBundle
                                         isDirectory:NO];
  if ([fm isExecutableFileAtPath:helperURL.path]) {
    return helperURL;
  }
  return nil;
}

/**
 * Writes an empty file to the path the updater uses as a "product was active"
 * sentinel for the provided app ID. Stomps on any file already at this path.
 * Does not validate the app ID; app IDs are assumed not to be under user
 * control, so a malicious string here could theoretically be some kind of
 * weird directory traversal attack.
 */
- (BOOL)syncWriteActiveFileWithError:(NSError**)error {
  NSFileManager* fm = [NSFileManager defaultManager];
  NSURL* library = [fm URLForDirectory:NSLibraryDirectory
                              inDomain:NSUserDomainMask
                     appropriateForURL:nil
                                create:NO
                                 error:error];
  if (!library) {
    return NO;
  }
  NSString* activesPathUnderLibrary =
      [NSString stringWithFormat:@"%s/%s/Actives", COMPANY_SHORTNAME_STRING,
                                 KEYSTONE_NAME];
  NSURL* activesPath =
      [library URLByAppendingPathComponent:activesPathUnderLibrary
                               isDirectory:YES];
  if (![fm createDirectoryAtURL:activesPath
          withIntermediateDirectories:YES
                           attributes:nil
                                error:error]) {
    return NO;
  }
  NSURL* target = [activesPath URLByAppendingPathComponent:_appId
                                               isDirectory:NO];
  return [@"" writeToFile:target.path
               atomically:NO
                 encoding:NSUTF8StringEncoding
                    error:error];
}

@end

#pragma mark - CRURegistration (VisibleForTesting)

@implementation CRURegistration (VisibleForTesting)

- (void)addWorkItems:(NSArray<CRURegistrationWorkItem*>*)items {
  dispatch_async(_privateQueue, ^{
    [self->_pendingWork addObjectsFromArray:items];
    [self syncMaybeStartMoreWork];
  });
}

- (NSURL*)syncFindBestKSAdmin {
  NSFileManager* fm = [NSFileManager defaultManager];
  NSArray<NSURL*>* libraries =
      [fm URLsForDirectory:NSLibraryDirectory
                 inDomains:NSUserDomainMask | NSLocalDomainMask];
  NSString* ksadminPathUnderLibrary = [NSString
      stringWithFormat:@"%s/%s/%s.bundle/Contents/Helpers/ksadmin",
                       COMPANY_SHORTNAME_STRING, KEYSTONE_NAME, KEYSTONE_NAME];
  // URLsForDirectory returns paths in ascending order of domain mask values.
  // To match Keystone's behavior, we prefer local domain (machine install) over
  // user domain; local domain has the higher numerical value, so we test
  // these in reverse order.
  for (NSURL* library in libraries.reverseObjectEnumerator) {
    NSURL* candidate =
        [library URLByAppendingPathComponent:ksadminPathUnderLibrary
                                 isDirectory:NO];
    if ([fm isExecutableFileAtPath:candidate.path]) {
      return candidate;
    }
  }
  return nil;
}

- (NSError*)wrapError:(NSError*)error
           withStdout:(NSString*)gotStdout
            andStderr:(NSString*)gotStderr {
  if (!error) {
    return nil;
  }

  // Check for errors already ready for user presentation.
  if ([error.domain isEqual:CRURegistrationErrorDomain] ||
      [error.domain isEqual:CRURegistrationInternalErrorDomain]) {
    return error;
  }

  // We're going to need to wrap this error. Start with common error info.
  NSMutableDictionary* userInfo = [NSMutableDictionary
      dictionaryWithDictionary:@{NSUnderlyingErrorKey : error}];
  if (gotStdout) {
    userInfo[CRUStdoutKey] = gotStdout;
  }
  if (gotStderr) {
    userInfo[CRUStderrKey] = gotStderr;
  }
  id maybeFilePath = error.userInfo[NSFilePathErrorKey];
  if (maybeFilePath) {
    userInfo[NSFilePathErrorKey] = maybeFilePath;
  }
  id maybeURL = error.userInfo[NSURLErrorKey];
  if (maybeURL) {
    userInfo[NSURLErrorKey] = maybeURL;
  }

  // Check for helper task failure.
  if ([error.domain isEqual:CRUReturnCodeErrorDomain]) {
    userInfo[CRUReturnCodeKey] = @(error.code);
    return [NSError errorWithDomain:CRURegistrationErrorDomain
                               code:CRURegistrationErrorTaskFailed
                           userInfo:userInfo];
  }

  // Check for errors reported by NSTask if it cannot find the task, or the file
  // specified is not executable. NSTask returns the same error code for both.
  // This NSTask behavior was determined experimentally -- Apple does not
  // document the errors that NSTask can emit -- so promoting this to a
  // HelperNotFound error should be considered "best-efort".
  if ([error.domain isEqual:NSCocoaErrorDomain] &&
      error.code == NSFileNoSuchFileError) {
    return [NSError errorWithDomain:CRURegistrationErrorDomain
                               code:CRURegistrationErrorHelperNotFound
                           userInfo:userInfo];
  }

  // Unrecognized error.
  return [NSError errorWithDomain:CRURegistrationInternalErrorDomain
                             code:CRURegistrationInternalErrorUnrecognized
                         userInfo:userInfo];
}

@end