TNLCLI/TNLCLIExecution.m (329 lines of code) (raw):

// // TNLCLIExecution.m // tnlcli // // Created on 9/12/19. // Copyright © 2020 Twitter. All rights reserved. // #import <TwitterNetworkLayer/TwitterNetworkLayer.h> #import "TNLCLIError.h" #import "TNLCLIExecution.h" #import "TNLCLIPrint.h" #import "TNLCLIUtils.h" #import "TNLGlobalConfiguration+TNLCLI.h" #import "TNLMutableRequestConfiguration+TNLCLI.h" #pragma mark - Static Functions #define FAIL(err) \ ({\ _executionError = (err); \ TNLCLIPrintError(_executionError); \ return; \ }) #define SOFT_FAIL(err) \ ({\ NSError *err__ = (err); \ TNLCLIPrintError(err__); \ if (!_executionError) { \ _executionError = err__; \ } \ }) #pragma mark - TNLCLIExecution @interface TNLCLIExecution () @property (nonatomic, readonly, nullable) NSError *executionError; @property (nonatomic, readonly, nullable) TNLResponse *response; - (NSString *)sanitizePath:(NSString *)path; @end @interface TNLCLIExecution (TNLDelegate) <TNLRequestDelegate, TNLLogger> @end @implementation TNLCLIExecution - (instancetype)initWithContext:(TNLCLIExecutionContext *)context { if (self = [super init]) { _context = context; _executionError = context.contextError; } return self; } - (nullable NSError *)execute { @try { [self _execute]; } @catch (NSException *exception) { _executionError = TNLCLICreateError(TNLCLIErrorException, @{ NSDebugDescriptionErrorKey : @"Exception when executing", @"exception" : exception }); TNLCLIPrintError(_executionError); tnlcli_fprintf(stderr, "call stack:\n%s\n", exception.callStackSymbols.description.UTF8String); } return _executionError; } - (NSString *)sanitizePath:(NSString *)path { NSString *newPath = [path stringByExpandingTildeInPath]; if (!newPath.isAbsolutePath) { newPath = [self.context.currentDirectory stringByAppendingPathComponent:newPath]; } return newPath; } - (void)_execute { if (_executionError) { return; } TNLCLIExecutionContext *context = _context; /// Print the version? if (context.printVersion) { tnlcli_printf("%s version %s\n", context.executableName.UTF8String, [TNLGlobalConfiguration version].UTF8String); if (context.requestURLString.length == 0) { // just getting the version return; } } /// Global Config TNLGlobalConfiguration *globalConfig = [TNLGlobalConfiguration sharedInstance]; globalConfig.assertsEnabled = YES; globalConfig.logger = self; [globalConfig addAuthenticationChallengeHandler:self]; // Optionally update global config for (NSString *globalConfigSetting in context.globalConfigurations) { NSString *name, *value; if (TNLCLIParseColonSeparatedKeyValuePair(globalConfigSetting, &name, &value)) { (void)[globalConfig tnlcli_applySettingWithName:name value:value]; } else { TNLCLIPrintWarning([NSString stringWithFormat:@"'%@' is not in the expected format for a global configuration: 'name:value'. Skipping this global configuration.", globalConfigSetting]); } } /// Construct the request TNLMutableRequestConfiguration *configuration = nil; TNLMutableHTTPRequest *request = nil; // Init our request request = [[TNLMutableHTTPRequest alloc] initWithURL:[NSURL URLWithString:context.requestURLString]]; if (!request.URL) { FAIL(TNLCLICreateError(TNLCLIErrorInvalidURLArgument, @{ NSDebugDescriptionErrorKey : @"Request URL argument is not valid", @"url_arg" : context.requestURLString ?: @"<null>" })); } // Optionally set method if (context.requestMethodValueString) { request.HTTPMethodValue = TNLHTTPMethodFromString(context.requestMethodValueString); if (request.HTTPMethodValue == TNLHTTPMethodGET && ![context.requestMethodValueString isEqualToString:@"GET"]) { TNLCLIPrintWarning([NSString stringWithFormat:@"--request-method arg `%s` is not an HTTP Method, using `GET` instead", context.requestMethodValueString.UTF8String]); } } // Optionally set headers if (context.requestHeadersFilePath) { NSString *filePath = [self sanitizePath:context.requestHeadersFilePath]; NSData *requestHeadersData = [NSData dataWithContentsOfFile:filePath]; if (!requestHeadersData) { FAIL(TNLCLICreateError(TNLCLIErrorArgumentInputFileCannotBeRead, @{ NSDebugDescriptionErrorKey : @"--request-headers-file cannot be read", @"file_arg" : context.requestHeadersFilePath })); } NSError *error; NSDictionary *headers = [NSJSONSerialization JSONObjectWithData:requestHeadersData options:0 error:&error]; if (!headers) { FAIL(error ?: TNLCLICreateError(TNLCLIErrorUnknown, nil)); } if (![headers isKindOfClass:[NSDictionary class]]) { FAIL(TNLCLICreateError(TNLCLIErrorJSONParseFailure, @{ NSDebugDescriptionErrorKey : @"Failed to parse file's JSON as key-value-pairs", @"file_arg" : context.requestHeadersFilePath })); } request.allHTTPHeaderFields = headers; } // Optionally set more headers for (NSString *header in context.requestHeaders) { NSString *field, *value; if (TNLCLIParseColonSeparatedKeyValuePair(header, &field, &value)) { [request setValue:value forHTTPHeaderField:field]; } else { TNLCLIPrintWarning([NSString stringWithFormat:@"'%@' is not in the expected format for a header: 'Header: Value'. Skipping this header.", header]); } } // Optionally set body if (context.requestBodyFilePath) { request.HTTPBodyFilePath = [self sanitizePath:context.requestBodyFilePath]; } // Construct configuration if (context.requestConfigurationFilePath) { NSString *filePath = [self sanitizePath:context.requestConfigurationFilePath]; NSError *error; configuration = [TNLMutableRequestConfiguration tnlcli_configurationWithFile:filePath error:&error]; if (!configuration) { FAIL(error); } } if (!configuration) { configuration = [[TNLMutableRequestConfiguration alloc] init]; } // Optionally update the configuration for (NSString *config in context.requestConfigurations) { NSString *name, *value; if (TNLCLIParseColonSeparatedKeyValuePair(config, &name, &value)) { [configuration tnlcli_applySettingWithName:name value:value]; } else { TNLCLIPrintWarning([NSString stringWithFormat:@"'%@' is not in the expected format for a request config seting: 'Name:Value'. Skipping this setting.", config]); } } /// Run our operation TNLRequestOperation *operation = [TNLRequestOperation operationWithRequest:request configuration:configuration delegate:self]; [[TNLRequestOperationQueue defaultOperationQueue] enqueueRequestOperation:operation]; [operation waitUntilFinishedWithoutBlockingRunLoop]; /// Handle our response // Was there an error if (_response.operationError) { SOFT_FAIL(_response.operationError); } // Verbose Info if (context.verbose) { tnlcli_printf("** STATS **\n"); NSDictionary *metricsDescription = [_response.metrics dictionaryDescription:YES]; NSError *error; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:metricsDescription options:(NSJSONWritingSortedKeys | NSJSONWritingPrettyPrinted) error:&error]; if (jsonData) { jsonData = TNLCLIEnsureDataIsNullTerminated(jsonData); tnlcli_printf("%s\n", (const char *)jsonData.bytes); } else { tnlcli_printf("Failed To Generate Stats! "); TNLCLIPrintError(error); } } // Response headers do { NSMutableDictionary *dictionary = [[_response.info allHTTPHeaderFields] mutableCopy]; dictionary[@"_tnlcli_StatusCode"] = [@(_response.info.statusCode) stringValue]; dictionary[@"_tnlcli_URL"] = [_response.info.finalURL absoluteString]; NSError *error; NSData *jsonData = (dictionary) ? [NSJSONSerialization dataWithJSONObject:dictionary options:NSJSONWritingSortedKeys | NSJSONWritingPrettyPrinted error:&error] : nil; if (!jsonData) { SOFT_FAIL(error); } else { jsonData = TNLCLIEnsureDataIsNullTerminated(jsonData); if (context.verbose || [context.responseHeadersOutputModes containsObject:@"print"]) { if (context.verbose) { tnlcli_printf("** RESPONSE HEADERS **\n"); } tnlcli_printf("%s\n", (const char *)jsonData.bytes); } if ([context.responseHeadersOutputModes containsObject:@"file"] && context.requestBodyFilePath) { NSString *filePath = [self sanitizePath:context.requestBodyFilePath]; if (![jsonData writeToFile:filePath options:NSDataWritingAtomic | NSDataWritingWithoutOverwriting error:&error]) { SOFT_FAIL(error); } } } } while (0); // Response body if (_response.info.data || _response.info.temporarySavedFile) { NSData *data = _response.info.data; BOOL writeToFile = [context.responseBodyOutputModes containsObject:@"file"] && context.responseBodyTargetFilePath; const BOOL print = context.verbose || [context.responseBodyOutputModes containsObject:@"print"]; if (_response.info.temporarySavedFile) { NSString *path = nil; if (writeToFile) { path = [self sanitizePath:context.responseBodyTargetFilePath]; writeToFile = NO; } else if (print) { [[NSFileManager defaultManager] createDirectoryAtPath:NSTemporaryDirectory() withIntermediateDirectories:YES attributes:nil error:NULL]; path = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSUUID UUID].UUIDString]; } if (path) { NSError *error; if (![_response.info.temporarySavedFile moveToPath:path error:&error]) { SOFT_FAIL(error); } else { if (print && !data) { data = [NSData dataWithContentsOfFile:path]; } } } } if (data) { if (writeToFile) { NSError *error; if (![data writeToFile:[self sanitizePath:context.responseBodyTargetFilePath] options:NSDataWritingAtomic | NSDataWritingWithoutOverwriting error:&error]) { SOFT_FAIL(error); } } if (print) { data = TNLCLIEnsureDataIsNullTerminated(data); NSString *printable = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (context.verbose) { tnlcli_printf("** RESPONSE BODY **\n"); } if (!printable) { if (context.verbose && ![context.responseBodyOutputModes containsObject:@"print"]) { // due to being verbose tnlcli_printf("Response body is not UTF-8 and cannot be printed.\n"); } else { SOFT_FAIL(TNLCLICreateError(TNLCLIErrorResponseBodyCannotPrint, @"The response body is not UTF-8 and therefore cannot be printed")); } } else { tnlcli_printf("\r\n%s\n", printable.UTF8String); } } } } } @end @implementation TNLCLIExecution (TNLDelegate) - (void)tnl_logWithLevel:(TNLLogLevel)level context:(nullable id)context file:(NSString *)file function:(NSString *)function line:(int)line message:(NSString *)message { static const char * sLevelStrings[] = { "EMGCY", "ALERT", "CRTCL", "ERROR", "WARNG", "Notce", "Info ", "Debug" }; tnlcli_fprintf((level >= TNLLogLevelNotice) ? stdout : stderr, "%s: %s\n", sLevelStrings[level], message.UTF8String); } - (BOOL)tnl_shouldRedactHTTPHeaderField:(NSString *)headerField { return NO; } - (BOOL)tnl_canLogWithLevel:(TNLLogLevel)level context:(nullable id)context { if (!_context.verbose) { return NO; } return (level <= TNLLogLevelWarning); } - (BOOL)tnl_shouldLogVerbosely { return _context.verbose; } - (void)tnl_requestOperation:(TNLRequestOperation *)op didCompleteWithResponse:(TNLResponse *)response { _response = response; } - (void)tnl_networkLayerDidReceiveAuthChallenge:(NSURLAuthenticationChallenge *)challenge requestOperation:(TNLRequestOperation *)op completion:(TNLURLSessionAuthChallengeCompletionBlock)completion { if (self.context.certificateChainDumpDirectory) { if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { NSString *host = challenge.protectionSpace.host; NSString *dumpDir = [self sanitizePath:self.context.certificateChainDumpDirectory]; NSFileManager *fm = [NSFileManager defaultManager]; NSError *error; if (![fm createDirectoryAtPath:dumpDir withIntermediateDirectories:YES attributes:nil error:&error]) { TNLCLIPrintError(error); } SecTrustRef serverTrust = challenge.protectionSpace.serverTrust; const CFIndex chainLength = SecTrustGetCertificateCount(serverTrust); if (chainLength > 0 && self.context.verbose) { tnlcli_printf("** CERT DUMP **\n"); } for (CFIndex i = 0; i < chainLength; i++) { SecCertificateRef certificate = SecTrustGetCertificateAtIndex(serverTrust, i); NSData *DERData = (NSData *)CFBridgingRelease(SecCertificateCopyData(certificate)); NSString *DERFilePath = [dumpDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@_cert_%li.DER", host, i]]; NSString *summary = (NSString *)CFBridgingRelease(CFCopyDescription(certificate)); if (![DERData writeToFile:DERFilePath options:NSDataWritingWithoutOverwriting error:&error]) { NSMutableDictionary *errorInfo = [error.userInfo mutableCopy] ?: [[NSMutableDictionary alloc] init]; errorInfo[@"cert.description"] = summary; TNLCLIPrintError([NSError errorWithDomain:error.domain code:error.code userInfo:errorInfo]); } else if (self.context.verbose) { tnlcli_printf("'%s' => %s\n", summary.UTF8String, DERFilePath.UTF8String); } } } } completion(NSURLSessionAuthChallengePerformDefaultHandling, nil); } @end