Files
Sparkle/Autoupdate/SUBinaryDeltaCreate.m
2024-10-13 16:58:54 -07:00

1045 lines
44 KiB
Objective-C

//
// SUBinaryDeltaCreate.m
// Sparkle
//
// Created by Mayur Pawashe on 4/9/15.
// Copyright (c) 2015 Sparkle Project. All rights reserved.
//
#import "SUBinaryDeltaCreate.h"
#import <Foundation/Foundation.h>
#include "SUBinaryDeltaCommon.h"
#import "SPUDeltaArchiveProtocol.h"
#import "SPUSparkleDeltaArchive.h"
#import "SPUXarDeltaArchive.h"
#import <CommonCrypto/CommonDigest.h>
#include <fcntl.h>
#include <fts.h>
#include <libgen.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/param.h>
#include <sys/stat.h>
#include <unistd.h>
#include <sys/xattr.h>
#include "AppKitPrevention.h"
extern int bsdiff(int argc, const char **argv);
@interface CreateBinaryDeltaOperation : NSOperation
@property (nonatomic, copy, readonly) NSString *relativePath;
@property (nonatomic, copy, readonly) NSString *clonedRelativePath;
@property (nonatomic, readonly) NSString *resultPath;
@property (nonatomic, readonly) NSNumber *oldPermissions;
@property (nonatomic, readonly) NSNumber *permissions;
@property (nonatomic, readonly) NSString *fromPath;
@property (nonatomic, readonly) BOOL changingPermissions;
- (id)initWithRelativePath:(NSString *)relativePath clonedRelativePath:(NSString *)clonedRelativePath oldTree:(NSString *)oldTree newTree:(NSString *)newTree oldPermissions:(NSNumber *)oldPermissions newPermissions:(NSNumber *)permissions changingPermissions:(BOOL)changingPermissions SPU_OBJC_DIRECT;
@end
@implementation CreateBinaryDeltaOperation
{
NSString *_toPath;
}
@synthesize relativePath = _relativePath;
@synthesize clonedRelativePath = _clonedRelativePath;
@synthesize resultPath = _resultPath;
@synthesize oldPermissions = _oldPermissions;
@synthesize permissions = _permissions;
@synthesize fromPath = _fromPath;
@synthesize changingPermissions = _changingPermissions;
- (id)initWithRelativePath:(NSString *)relativePath clonedRelativePath:(NSString *)clonedRelativePath oldTree:(NSString *)oldTree newTree:(NSString *)newTree oldPermissions:(NSNumber *)oldPermissions newPermissions:(NSNumber *)permissions changingPermissions:(BOOL)changingPermissions
{
if ((self = [super init])) {
_relativePath = [relativePath copy];
_clonedRelativePath = [clonedRelativePath copy];
_oldPermissions = oldPermissions;
_permissions = permissions;
_changingPermissions = changingPermissions;
if (clonedRelativePath == nil) {
_fromPath = [oldTree stringByAppendingPathComponent:relativePath];
} else {
_fromPath = [oldTree stringByAppendingPathComponent:clonedRelativePath];
}
_toPath = [newTree stringByAppendingPathComponent:relativePath];
}
return self;
}
- (void)main
{
NSString *temporaryFile = temporaryFilename(@"BinaryDelta");
const char *argv[] = { "/usr/bin/bsdiff", [_fromPath fileSystemRepresentation], [_toPath fileSystemRepresentation], [temporaryFile fileSystemRepresentation] };
int result = bsdiff(4, argv);
if (result == 0) {
_resultPath = temporaryFile;
}
}
@end
#define INFO_PATH_KEY @"path"
#define INFO_TYPE_KEY @"type"
#define INFO_PERMISSIONS_KEY @"permissions"
#define INFO_SIZE_KEY @"size"
static NSDictionary *infoForFile(FTSENT *ent)
{
off_t size = (ent->fts_info != FTS_D) ? ent->fts_statp->st_size : 0;
assert(ent->fts_statp != NULL);
mode_t permissions = ent->fts_statp->st_mode & PERMISSION_FLAGS;
NSString *path = @(ent->fts_path);
assert(path != nil);
return @{ INFO_PATH_KEY: path != nil ? path : @"",
INFO_TYPE_KEY: @(ent->fts_info),
INFO_PERMISSIONS_KEY: @(permissions),
INFO_SIZE_KEY: @(size) };
}
static bool aclExists(const FTSENT *ent)
{
// macOS does not currently support ACLs for symlinks
if (ent->fts_info == FTS_SL) {
return NO;
}
acl_t acl = acl_get_link_np(ent->fts_path, ACL_TYPE_EXTENDED);
if (acl != NULL) {
acl_entry_t entry;
int result = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry);
assert(acl_free((void *)acl) == 0);
return (result == 0);
}
return false;
}
static bool codeSignatureExtendedAttributeExists(const FTSENT *ent)
{
const int options = XATTR_NOFOLLOW;
ssize_t listSize = listxattr(ent->fts_path, NULL, 0, options);
if (listSize == -1) {
return false;
}
char *buffer = malloc((size_t)listSize);
assert(buffer != NULL);
ssize_t sizeBack = listxattr(ent->fts_path, buffer, (size_t)listSize, options);
assert(sizeBack == listSize);
size_t startCharacterIndex = 0;
for (size_t characterIndex = 0; characterIndex < (size_t)listSize; characterIndex++) {
if (buffer[characterIndex] == '\0') {
char *attribute = &buffer[startCharacterIndex];
size_t length = characterIndex - startCharacterIndex;
if (strncmp(APPLE_CODE_SIGN_XATTR_CODE_DIRECTORY_KEY, attribute, length) == 0 || strncmp(APPLE_CODE_SIGN_XATTR_CODE_REQUIREMENTS_KEY, attribute, length) == 0 || strncmp(APPLE_CODE_SIGN_XATTR_CODE_SIGNATURE_KEY, attribute, length) == 0) {
free(buffer);
return true;
}
startCharacterIndex = characterIndex + 1;
}
}
free(buffer);
return false;
}
static NSString *absolutePath(NSString *path)
{
NSURL *url = [[NSURL alloc] initFileURLWithPath:path];
return [[url absoluteURL] path];
}
static NSString *temporaryPatchFile(NSString *patchFile)
{
NSString *path = absolutePath(patchFile);
NSString *directory = [path stringByDeletingLastPathComponent];
NSString *file = [path lastPathComponent];
return [NSString stringWithFormat:@"%@/.%@.tmp", directory, file];
}
#define MIN_FILE_SIZE_FOR_CREATING_DELTA 4096
static BOOL shouldSkipDeltaCompression(NSDictionary *originalInfo, NSDictionary *newInfo)
{
unsigned long long fileSize = [(NSNumber *)newInfo[INFO_SIZE_KEY] unsignedLongLongValue];
if (fileSize < MIN_FILE_SIZE_FOR_CREATING_DELTA) {
return YES;
}
if (!originalInfo) {
return YES;
}
unsigned short originalInfoType = [(NSNumber *)originalInfo[INFO_TYPE_KEY] unsignedShortValue];
unsigned short newInfoType = [(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue];
if (originalInfoType != newInfoType || originalInfoType != FTS_F) {
// File types are different or they're not regular files
return YES;
}
NSString *originalPath = originalInfo[INFO_PATH_KEY];
NSString *newPath = newInfo[INFO_PATH_KEY];
// Skip delta if the files are equal in content
if ([[NSFileManager defaultManager] contentsEqualAtPath:originalPath andPath:newPath]) {
return YES;
}
return NO;
}
static BOOL shouldDeleteThenExtract(NSDictionary *originalInfo, NSDictionary *newInfo)
{
if (!originalInfo) {
return NO;
}
if ([(NSNumber *)originalInfo[INFO_TYPE_KEY] unsignedShortValue] != [(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue]) {
return YES;
}
return NO;
}
static BOOL shouldSkipExtracting(NSDictionary *originalInfo, NSDictionary *newInfo)
{
if (!originalInfo) {
return NO;
}
unsigned short originalInfoType = [(NSNumber *)originalInfo[INFO_TYPE_KEY] unsignedShortValue];
unsigned short newInfoType = [(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue];
if (originalInfoType != newInfoType) {
// File types are different
return NO;
}
NSString *originalPath = originalInfo[INFO_PATH_KEY];
NSString *newPath = newInfo[INFO_PATH_KEY];
// Don't skip extract if files/symlinks entries are not equal in content
// (note if the entries are directories, they are equal)
if (originalInfoType != FTS_D && ![[NSFileManager defaultManager] contentsEqualAtPath:originalPath andPath:newPath]) {
return NO;
}
return YES;
}
static BOOL shouldChangePermissions(NSDictionary *originalInfo, NSDictionary *newInfo)
{
if (!originalInfo) {
return NO;
}
unsigned short originalInfoType = [(NSNumber *)originalInfo[INFO_TYPE_KEY] unsignedShortValue];
unsigned short newInfoType = [(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue];
if (originalInfoType != newInfoType) {
return NO;
}
unsigned short oldPermissions = [(NSNumber *)originalInfo[INFO_PERMISSIONS_KEY] unsignedShortValue];
unsigned short newPermissions = [(NSNumber *)newInfo[INFO_PERMISSIONS_KEY] unsignedShortValue];
if (oldPermissions == newPermissions) {
return NO;
}
// We don't track new permissions on symbolic links that aren't the 0755 macOS default
// Some linux / remotely mounted filesystems may not track permissions on symlinks and use 0777
// We don't want to pick up bad permissions
if (newInfoType == FTS_SL && newPermissions != VALID_SYMBOLIC_LINK_PERMISSIONS) {
return NO;
}
return YES;
}
#define MIN_SIZE_FOR_CLONE 4096
#define MIN_SIZE_FOR_CLONE_DIFF (4096 * 4)
static NSString *cloneableRelativePath(NSDictionary<NSString *, NSData *> *afterFileKeyToHashDictionary, NSDictionary<NSData *, NSArray<NSString *> *> *beforeHashToFileKeyDictionary, NSDictionary<NSString *, NSString *> *frameworkVersionsSubstitutes, NSDictionary<NSString *, NSString *> *fileSubstitutes, NSDictionary *originalTreeState, NSDictionary *newInfo, NSString *key, NSNumber * __autoreleasing *outNewPermissions, BOOL *clonePermissionsChanged, BOOL *clonedBinaryDiff)
{
// Avoid clones for small files. Small files can compress very well, sometimes better than tracking clones.
if ([(NSNumber *)newInfo[INFO_SIZE_KEY] unsignedLongLongValue] <= MIN_SIZE_FOR_CLONE) {
return nil;
}
if ([(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue] != FTS_F) {
return nil;
}
{
NSData *keyHash = afterFileKeyToHashDictionary[key];
if (keyHash != nil) {
// Check for identical clones first
for (NSString *oldRelativePath in beforeHashToFileKeyDictionary[keyHash]) {
NSDictionary *oldCloneInfo = originalTreeState[oldRelativePath];
if (oldCloneInfo == nil) {
continue;
}
if ([(NSNumber *)oldCloneInfo[INFO_TYPE_KEY] unsignedShortValue] != FTS_F) {
continue;
}
NSString *clonePath = oldCloneInfo[INFO_PATH_KEY];
NSString *newPath = newInfo[INFO_PATH_KEY];
if (![[NSFileManager defaultManager] contentsEqualAtPath:clonePath andPath:newPath]) {
continue;
}
NSNumber *newPermissions = newInfo[INFO_PERMISSIONS_KEY];
if (outNewPermissions != NULL) {
*outNewPermissions = newPermissions;
}
if (clonePermissionsChanged != NULL) {
*clonePermissionsChanged = ([(NSNumber *)oldCloneInfo[INFO_PERMISSIONS_KEY] unsignedShortValue] != [newPermissions unsignedShortValue]);
}
if (clonedBinaryDiff != NULL) {
*clonedBinaryDiff = NO;
}
return oldRelativePath;
}
}
}
// For non-identical files where we do a binary diff, make sure file size matches a more strict file size test
if ([(NSNumber *)newInfo[INFO_SIZE_KEY] unsignedLongLongValue] <= MIN_SIZE_FOR_CLONE_DIFF) {
return nil;
}
// Look out for any .framework/Versions/{A -> B} changes
for (NSString *frameworkVersionPrefix in frameworkVersionsSubstitutes) {
if (![key hasPrefix:frameworkVersionPrefix]) {
continue;
}
NSString *cloneFrameworkSubstitutePrefix = frameworkVersionsSubstitutes[frameworkVersionPrefix];
if (cloneFrameworkSubstitutePrefix == nil) {
continue;
}
NSString *cloneRelativeKey = [key stringByReplacingCharactersInRange:NSMakeRange(0, frameworkVersionPrefix.length) withString:cloneFrameworkSubstitutePrefix];
NSDictionary *oldCloneInfo = originalTreeState[cloneRelativeKey];
if (oldCloneInfo == nil) {
continue;
}
NSNumber *newPermissions = newInfo[INFO_PERMISSIONS_KEY];
if (outNewPermissions != NULL) {
*outNewPermissions = newPermissions;
}
if (clonePermissionsChanged != NULL) {
*clonePermissionsChanged = ([(NSNumber *)oldCloneInfo[INFO_PERMISSIONS_KEY] unsignedShortValue] != [newPermissions unsignedShortValue]);
}
if (clonedBinaryDiff != NULL) {
*clonedBinaryDiff = YES;
}
return cloneRelativeKey;
}
// Look out for any changes that involve the same named file moving to another directory
do {
NSString *cloneRelativeKey = fileSubstitutes[key.lastPathComponent];
if (cloneRelativeKey == nil) {
break;
}
NSDictionary *oldCloneInfo = originalTreeState[cloneRelativeKey];
if (oldCloneInfo == nil) {
break;
}
uint64_t cloneSize = [(NSNumber *)oldCloneInfo[INFO_SIZE_KEY] unsignedLongValue];
uint64_t newSize = [(NSNumber *)newInfo[INFO_SIZE_KEY] unsignedLongValue];
uint64_t minSize = MIN(cloneSize, newSize);
uint64_t maxSize = MAX(cloneSize, newSize);
// Ensure file is at least 60% the same size
if (minSize == 0 || maxSize == 0 || (double)minSize / (double)maxSize < 0.60) {
break;
}
NSNumber *newPermissions = newInfo[INFO_PERMISSIONS_KEY];
if (outNewPermissions != NULL) {
*outNewPermissions = newPermissions;
}
if (clonePermissionsChanged != NULL) {
*clonePermissionsChanged = ([(NSNumber *)oldCloneInfo[INFO_PERMISSIONS_KEY] unsignedShortValue] != [newPermissions unsignedShortValue]);
}
if (clonedBinaryDiff != NULL) {
*clonedBinaryDiff = YES;
}
return cloneRelativeKey;
} while (NO);
return nil;
}
BOOL createBinaryDelta(NSString *source, NSString *destination, NSString *patchFile, SUBinaryDeltaMajorVersion majorVersion, SPUDeltaCompressionMode compression, uint8_t compressionLevel, BOOL verbose, NSError *__autoreleasing *error)
{
assert(source);
assert(destination);
assert(patchFile);
assert(majorVersion >= SUBinaryDeltaMajorVersionFirst && majorVersion <= SUBinaryDeltaMajorVersionLatest);
uint16_t minorVersion = latestMinorVersionForMajorVersion(majorVersion);
NSMutableDictionary *originalTreeState = [NSMutableDictionary dictionary];
char pathBuffer[PATH_MAX] = { 0 };
if (![source getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) {
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadInvalidFileNameError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve file system path representation from source %@", source] }];
}
return NO;
}
char *sourcePaths[] = { pathBuffer, 0 };
FTS *fts = fts_open(sourcePaths, FTS_PHYSICAL | FTS_NOCHDIR, compareFiles);
if (!fts) {
if (error != NULL) {
*error = [NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"fts_open failed on source: %@", @(strerror(errno))] }];
}
return NO;
}
if (verbose) {
fprintf(stderr, "Creating version %u.%u patch using %s compression...\n", majorVersion, minorVersion, deltaCompressionStringFromMode(compression).UTF8String);
fprintf(stderr, "Processing source, %s...", [source fileSystemRepresentation]);
}
FTSENT *ent = 0;
while ((ent = fts_read(fts))) {
if (ent->fts_info != FTS_F && ent->fts_info != FTS_SL && ent->fts_info != FTS_D) {
continue;
}
NSString *key = pathRelativeToDirectory(source, stringWithFileSystemRepresentation(ent->fts_path));
if (![key length]) {
continue;
}
if ([key isEqualToString:CUSTOM_ICON_PATH]) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing bundles with a custom icon set via a resource fork is not supported. Detected presence of %@", @(ent->fts_path)] }];
}
return NO;
}
NSDictionary *info = infoForFile(ent);
if (!info) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve info for file %@", @(ent->fts_path)] }];
}
return NO;
}
originalTreeState[key] = info;
// Ensure Sparkle executable permissions are valid
if (ent->fts_info == FTS_F && [key.lastPathComponent isEqualToString:@"Sparkle"] && [key.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent.lastPathComponent isEqualToString:@"Sparkle.framework"]) {
mode_t permissions = (mode_t)[(NSNumber *)info[INFO_PERMISSIONS_KEY] shortValue];
if (permissions != VALID_SPARKLE_EXECUTABLE_PERMISSIONS) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Permissions for Sparkle executable must be 0%o (found 0%o) on file %@", VALID_SPARKLE_EXECUTABLE_PERMISSIONS, permissions, @(ent->fts_path)] }];
}
return NO;
}
}
if (aclExists(ent)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing ACLs are not supported. Detected ACL in before-tree on file %@", @(ent->fts_path)] }];
}
return NO;
}
if (codeSignatureExtendedAttributeExists(ent)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing code signed extended attributes are not supported. Detected extended attribute in before-tree on file %@. For removing code signed extended attributes and improving your bundle's structure, please see https://developer.apple.com/documentation/bundleresources/placing_content_in_a_bundle", @(ent->fts_path)] }];
}
return NO;
}
}
fts_close(fts);
// This dictionary will help us keep track of clones
NSMutableDictionary<NSData *, NSMutableArray<NSString *> *> *beforeHashToFileKeyDictionary = (majorVersion >= SUBinaryDeltaMajorVersion3) ? [NSMutableDictionary dictionary] : nil;
unsigned char beforeHash[BINARY_DELTA_HASH_LENGTH] = {0};
if (!getRawHashOfTreeAndFileTablesWithVersion(beforeHash, source, majorVersion, beforeHashToFileKeyDictionary, nil)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to generate hash for tree %@", source] }];
}
return NO;
}
NSMutableDictionary *newTreeState = [NSMutableDictionary dictionary];
for (NSString *key in originalTreeState) {
newTreeState[key] = [NSNull null];
}
if (verbose) {
fprintf(stderr, "\nProcessing destination, %s...", [destination fileSystemRepresentation]);
}
pathBuffer[0] = 0;
if (![destination getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadInvalidFileNameError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve file system path representation from destination %@", destination] }];
}
return NO;
}
sourcePaths[0] = pathBuffer;
fts = fts_open(sourcePaths, FTS_PHYSICAL | FTS_NOCHDIR, compareFiles);
if (!fts) {
if (error != NULL) {
*error = [NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"fts_open failed on destination: %@", @(strerror(errno))] }];
}
return NO;
}
bool foundFilesystemCompression = false;
uint32_t warningsCount = 0;
const uint32_t maxWarningsToPrint = 16;
while ((ent = fts_read(fts))) {
if (ent->fts_info != FTS_F && ent->fts_info != FTS_SL && ent->fts_info != FTS_D) {
continue;
}
NSString *key = pathRelativeToDirectory(destination, stringWithFileSystemRepresentation(ent->fts_path));
if (![key length]) {
continue;
}
if ([key isEqualToString:CUSTOM_ICON_PATH]) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing bundles with a custom icon set via a resource fork is not supported. Detected presence of %@", @(ent->fts_path)] }];
}
return NO;
}
NSDictionary *info = infoForFile(ent);
if (!info) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to retrieve info from file %@", @(ent->fts_path)] }];
}
return NO;
}
// Ensure Sparkle executable permissions are valid
if (ent->fts_info == FTS_F && [key.lastPathComponent isEqualToString:@"Sparkle"] && [key.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent.lastPathComponent isEqualToString:@"Sparkle.framework"]) {
mode_t permissions = (mode_t)[(NSNumber *)info[INFO_PERMISSIONS_KEY] shortValue];
if (permissions != VALID_SPARKLE_EXECUTABLE_PERMISSIONS) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Permissions for Sparkle executable must be 0%o (found 0%o) on file %@", VALID_SPARKLE_EXECUTABLE_PERMISSIONS, permissions, @(ent->fts_path)] }];
}
return NO;
}
}
// We should validate ACLs even if we don't store the info in the diff in the case of ACLs
// We should also not allow files with code signed extended attributes since Apple doesn't recommend inserting these
// inside an application, and since we don't preserve extended attribitutes anyway
if (aclExists(ent)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing ACLs are not supported. Detected ACL in after-tree on file %@", @(ent->fts_path)] }];
}
return NO;
}
if (codeSignatureExtendedAttributeExists(ent)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Diffing code signed extended attributes are not supported. Detected extended attribute in after-tree on file %@. For removing code signed extended attributes and improving your bundle's structure, please see https://developer.apple.com/documentation/bundleresources/placing_content_in_a_bundle", @(ent->fts_path)] }];
}
return NO;
}
if (warningsCount < maxWarningsToPrint) {
uint16_t permissions = (ent->fts_statp->st_mode & PERMISSION_FLAGS);
if (ent->fts_info == FTS_SL) {
if (permissions != VALID_SYMBOLIC_LINK_PERMISSIONS) {
fprintf(stderr, "\nWarning: file permissions 0%o of symbolic link '%s' won't be preserved in the delta update (only permissions with mode 0755 are supported for symbolic links).", permissions, ent->fts_path);
warningsCount++;
}
} else if (permissions != 0755 && permissions != 0644) {
// This could indicate something is wrong inside of the bundle so it's worth warning the user about
fprintf(stderr, "\nWarning: detected irregular file permissions 0%o for '%s'", permissions, ent->fts_path);
warningsCount++;
}
if (warningsCount == maxWarningsToPrint) {
fprintf(stderr, "\nWarning: encountered too many warnings.. Ignoring the rest..");
}
}
// If we find any executable files that are using file system compression, that is sufficient
// for recording that the applier should re-apply file system compression.
// We check for executable files because they are likely candidates to be compressed.
if (!foundFilesystemCompression && (majorVersion >= SUBinaryDeltaMajorVersion3) && ent->fts_info == FTS_F && (ent->fts_statp->st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0 && (ent->fts_statp->st_flags & UF_COMPRESSED) != 0) {
foundFilesystemCompression = true;
if (verbose) {
fprintf(stderr, " File system compression detected.");
}
}
NSDictionary *oldInfo = originalTreeState[key];
BOOL hasEqualInfo;
if (![info isEqual:oldInfo]) {
hasEqualInfo = NO;
} else {
NSString *originalPath = oldInfo[INFO_PATH_KEY];
NSString *newPath = info[INFO_PATH_KEY];
hasEqualInfo = [[NSFileManager defaultManager] contentsEqualAtPath:originalPath andPath:newPath];
}
if (hasEqualInfo) {
[newTreeState removeObjectForKey:key];
} else {
newTreeState[key] = info;
if (oldInfo && [(NSNumber *)oldInfo[INFO_TYPE_KEY] unsignedShortValue] == FTS_D && [(NSNumber *)info[INFO_TYPE_KEY] unsignedShortValue] != FTS_D) {
NSArray *parentPathComponents = key.pathComponents;
for (NSString *childPath in originalTreeState) {
NSArray *childPathComponents = childPath.pathComponents;
if (childPathComponents.count > parentPathComponents.count &&
[parentPathComponents isEqualToArray:[childPathComponents subarrayWithRange:NSMakeRange(0, parentPathComponents.count)]]) {
[newTreeState removeObjectForKey:childPath];
}
}
}
}
}
fts_close(fts);
// This dictionary will help us keep track of clones
NSMutableDictionary<NSString *, NSData *> *afterFileKeyToHashDictionary = (majorVersion >= SUBinaryDeltaMajorVersion3) ? [NSMutableDictionary dictionary] : nil;
unsigned char afterHash[BINARY_DELTA_HASH_LENGTH] = {0};
if (!getRawHashOfTreeAndFileTablesWithVersion(afterHash, destination, majorVersion, nil, afterFileKeyToHashDictionary)) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to generate hash for tree %@", destination] }];
}
return NO;
}
if (verbose) {
fprintf(stderr, "\nGenerating delta...");
}
NSString *temporaryFile = temporaryPatchFile(patchFile);
if (verbose) {
fprintf(stderr, "\nWriting to temporary file %s...", [temporaryFile fileSystemRepresentation]);
}
id<SPUDeltaArchiveProtocol> archive;
if (majorVersion >= SUBinaryDeltaMajorVersion3) {
archive = [[SPUSparkleDeltaArchive alloc] initWithPatchFileForWriting:temporaryFile];
} else {
#if SPARKLE_BUILD_LEGACY_DELTA_SUPPORT
archive = [[SPUXarDeltaArchive alloc] initWithPatchFileForWriting:temporaryFile];
#else
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadUnknownError userInfo:@{ NSLocalizedDescriptionKey: @"Support for creating legacy delta updates is disabled" }];
}
return NO;
#endif
}
// Record creation date of root bundle item
NSDate *bundleCreationDate;
if (majorVersion >= SUBinaryDeltaMajorVersion4) {
NSError *fileAttributesError = nil;
NSDictionary<NSFileAttributeKey, id> *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:destination error:&fileAttributesError];
if (fileAttributes != nil) {
bundleCreationDate = fileAttributes[NSFileCreationDate];
} else {
bundleCreationDate = nil;
fprintf(stderr, "\nWarning: unable to retrieve file creation date of new bundle: %s", fileAttributesError.localizedDescription.UTF8String);
}
} else {
bundleCreationDate = nil;
}
SPUDeltaArchiveHeader *header = [[SPUDeltaArchiveHeader alloc] initWithCompression:compression compressionLevel:compressionLevel fileSystemCompression:foundFilesystemCompression majorVersion:majorVersion minorVersion:minorVersion beforeTreeHash:beforeHash afterTreeHash:afterHash bundleCreationDate:bundleCreationDate];
[archive writeHeader:header];
if (archive.error != nil) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = archive.error;
}
return NO;
}
NSOperationQueue *deltaQueue = [[NSOperationQueue alloc] init];
NSMutableArray *deltaOperations = [NSMutableArray array];
// Sort the keys by preferring the ones from the original tree to appear first
// We want to enforce deleting before extracting in the case paths differ only by case
NSArray *keys = [[newTreeState allKeys] sortedArrayUsingComparator:^NSComparisonResult(NSString *key1, NSString *key2) {
NSComparisonResult insensitiveCompareResult = [key1 caseInsensitiveCompare:key2];
if (insensitiveCompareResult != NSOrderedSame) {
return insensitiveCompareResult;
}
return originalTreeState[key1] ? NSOrderedAscending : NSOrderedDescending;
}];
// Using a couple of heuristics we track if files have been moved to other locations within the app bundle
NSMutableDictionary<NSString *, NSString *> *frameworkVersionsSubstitutes = [NSMutableDictionary dictionary];
NSMutableDictionary<NSString *, NSString *> *fileSubstitutes = [NSMutableDictionary dictionary];
if (majorVersion >= SUBinaryDeltaMajorVersion3) {
// Heuristic #1: track if an old framework version was removed and a new framework version was added
// Keep track of these prefixes in a dictionary
// Eg: /Contents/Frameworks/Foo.framework/Versions/B/ (new) -> /Contents/Frameworks/Foo.framework/Versions/A/ (old)
NSMutableDictionary<NSString *, NSString *> *oldFrameworkVersions = [NSMutableDictionary dictionary];
for (NSString *key in keys) {
id value = [newTreeState valueForKey:key];
if (![(NSObject *)value isEqual:[NSNull null]]) {
continue;
}
NSDictionary *originalInfo = originalTreeState[key];
if ([(NSNumber *)originalInfo[INFO_TYPE_KEY] unsignedShortValue] != FTS_D) {
continue;
}
NSString *keyWithoutLastPathComponent = key.stringByDeletingLastPathComponent;
if (![keyWithoutLastPathComponent.lastPathComponent isEqualToString:@"Versions"]) {
continue;
}
NSString *keyWithoutLastLastPathComponent = keyWithoutLastPathComponent.stringByDeletingLastPathComponent;
if (![keyWithoutLastLastPathComponent.pathExtension isEqualToString:@"framework"]) {
continue;
}
oldFrameworkVersions[keyWithoutLastLastPathComponent] = key;
}
for (NSString *key in keys) {
id value = [newTreeState valueForKey:key];
if ([(NSObject *)value isEqual:[NSNull null]] || originalTreeState[key] != nil) {
continue;
}
NSDictionary *newInfo = value;
if ([(NSNumber *)newInfo[INFO_TYPE_KEY] unsignedShortValue] != FTS_D) {
continue;
}
NSString *keyWithoutLastPathComponent = key.stringByDeletingLastPathComponent;
if (![keyWithoutLastPathComponent.lastPathComponent isEqualToString:@"Versions"]) {
continue;
}
NSString *keyWithoutLastLastPathComponent = keyWithoutLastPathComponent.stringByDeletingLastPathComponent;
if (![keyWithoutLastLastPathComponent.pathExtension isEqualToString:@"framework"]) {
continue;
}
NSString *oldFrameworkVersionKey = oldFrameworkVersions[keyWithoutLastLastPathComponent];
if (oldFrameworkVersionKey == nil) {
continue;
}
frameworkVersionsSubstitutes[[key stringByAppendingString:@"/"]] = [oldFrameworkVersionKey stringByAppendingString:@"/"];
}
// Heuristic #2: Keep a table of removed filenames, collapsing them by the largest file per unique name
// This sees if a file has just been moved to another location
for (NSString *key in keys) {
id value = [newTreeState valueForKey:key];
if (![(NSObject *)value isEqual:[NSNull null]]) {
continue;
}
NSDictionary *keyInfo = originalTreeState[key];
if ([(NSNumber *)keyInfo[INFO_TYPE_KEY] unsignedShortValue] != FTS_F) {
continue;
}
NSString *lastPathComponent = key.lastPathComponent;
NSString *existingKey = fileSubstitutes[lastPathComponent];
if (existingKey == nil) {
fileSubstitutes[lastPathComponent] = key;
} else {
NSDictionary *existingKeyInfo = originalTreeState[existingKey];
if ([(NSNumber *)keyInfo[INFO_SIZE_KEY] unsignedLongValue] > [(NSNumber *)existingKeyInfo[INFO_SIZE_KEY] unsignedLongValue]) {
fileSubstitutes[lastPathComponent] = key;
}
}
}
}
for (NSString *key in keys) {
id value = [newTreeState valueForKey:key];
if ([(NSObject *)value isEqual:[NSNull null]]) {
[archive addItem:[[SPUDeltaArchiveItem alloc] initWithRelativeFilePath:key commands:SPUDeltaItemCommandDelete mode:0]];
if (verbose) {
fprintf(stderr, "\n❌ %s %s", VERBOSE_REMOVED, [key fileSystemRepresentation]);
}
continue;
}
NSDictionary *originalInfo = originalTreeState[key];
NSDictionary *newInfo = newTreeState[key];
if (shouldSkipDeltaCompression(originalInfo, newInfo)) {
if (shouldSkipExtracting(originalInfo, newInfo)) {
if (shouldChangePermissions(originalInfo, newInfo)) {
[archive addItem:[[SPUDeltaArchiveItem alloc] initWithRelativeFilePath:key commands:SPUDeltaItemCommandModifyPermissions mode:[(NSNumber *)newInfo[INFO_PERMISSIONS_KEY] unsignedShortValue]]];
if (verbose) {
fprintf(stderr, "\n👮 %s %s (0%o -> 0%o)", VERBOSE_MODIFIED, [key fileSystemRepresentation], [(NSNumber *)originalInfo[INFO_PERMISSIONS_KEY] unsignedShortValue], [(NSNumber *)newInfo[INFO_PERMISSIONS_KEY] unsignedShortValue]);
}
}
} else {
// Check if the new file can be cloned from an old existing one located at a different path
NSNumber *newPermissions = nil;
BOOL clonePermissionsChanged = NO;
BOOL clonedBinaryDiff = NO;
NSString *clonedRelativePath = (majorVersion >= SUBinaryDeltaMajorVersion3) ? cloneableRelativePath(afterFileKeyToHashDictionary, beforeHashToFileKeyDictionary, frameworkVersionsSubstitutes, fileSubstitutes, originalTreeState, newInfo, key, &newPermissions, &clonePermissionsChanged, &clonedBinaryDiff) : nil;
if (clonedRelativePath != nil) {
if (clonedBinaryDiff) {
NSDictionary *cloneInfo = originalTreeState[clonedRelativePath];
CreateBinaryDeltaOperation *operation = [[CreateBinaryDeltaOperation alloc] initWithRelativePath:key clonedRelativePath:clonedRelativePath oldTree:source newTree:destination oldPermissions:cloneInfo[INFO_PERMISSIONS_KEY] newPermissions:newPermissions changingPermissions:clonePermissionsChanged];
[deltaQueue addOperation:operation];
[deltaOperations addObject:operation];
} else {
SPUDeltaItemCommands commands = SPUDeltaItemCommandClone;
if (clonePermissionsChanged) {
commands |= SPUDeltaItemCommandModifyPermissions;
}
SPUDeltaArchiveItem *item = [[SPUDeltaArchiveItem alloc] initWithRelativeFilePath:key commands:commands mode:(clonePermissionsChanged ? newPermissions.unsignedShortValue : 0)];
// Physical path for clones points to the old file
item.itemFilePath = [source stringByAppendingPathComponent:clonedRelativePath];
item.sourcePath = item.itemFilePath;
item.clonedRelativePath = clonedRelativePath;
[archive addItem:item];
if (verbose) {
fprintf(stderr, "\n✂️ %s %s -> %s", VERBOSE_CLONED, [clonedRelativePath fileSystemRepresentation], [key fileSystemRepresentation]);
}
}
} else {
// Otherwise add a new file
NSString *path = [destination stringByAppendingPathComponent:key];
SPUDeltaItemCommands commands = SPUDeltaItemCommandExtract;
if (shouldDeleteThenExtract(originalInfo, newInfo)) {
commands |= SPUDeltaItemCommandDelete;
}
SPUDeltaArchiveItem *item = [[SPUDeltaArchiveItem alloc] initWithRelativeFilePath:key commands:commands mode:0];
item.itemFilePath = path;
item.sourcePath = path;
[archive addItem:item];
if (verbose) {
if (originalInfo) {
fprintf(stderr, "\n✏️ %s %s", VERBOSE_UPDATED, [key fileSystemRepresentation]);
} else {
fprintf(stderr, "\n✅ %s %s", VERBOSE_ADDED, [key fileSystemRepresentation]);
}
}
}
}
} else {
NSNumber *permissions = newInfo[INFO_PERMISSIONS_KEY];
CreateBinaryDeltaOperation *operation = [[CreateBinaryDeltaOperation alloc] initWithRelativePath:key clonedRelativePath:nil oldTree:source newTree:destination oldPermissions:originalInfo[INFO_PERMISSIONS_KEY] newPermissions:permissions changingPermissions:shouldChangePermissions(originalInfo, newInfo)];
[deltaQueue addOperation:operation];
[deltaOperations addObject:operation];
}
}
[deltaQueue waitUntilAllOperationsAreFinished];
BOOL deltaOperationsFailed = NO;
for (CreateBinaryDeltaOperation *operation in deltaOperations) {
NSString *resultPath = operation.resultPath;
if (resultPath == nil) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to create patch from source %@ and destination %@", operation.relativePath, resultPath] }];
}
deltaOperationsFailed = YES;
break;
}
NSString *clonedRelativePath = [operation clonedRelativePath];
if (verbose) {
if (clonedRelativePath == nil) {
fprintf(stderr, "\n🔨 %s %s", VERBOSE_DIFFED, [[operation relativePath] fileSystemRepresentation]);
} else {
fprintf(stderr, "\n🔨 %s %s -> %s", VERBOSE_DIFFED, [clonedRelativePath fileSystemRepresentation], [[operation relativePath] fileSystemRepresentation]);
}
}
NSNumber *mode = operation.permissions;
NSString *relativePath = operation.relativePath;
SPUDeltaItemCommands commands = SPUDeltaItemCommandBinaryDiff;
if (operation.changingPermissions) {
commands |= SPUDeltaItemCommandModifyPermissions;
}
if (clonedRelativePath != nil) {
commands |= SPUDeltaItemCommandClone;
}
SPUDeltaArchiveItem *item = [[SPUDeltaArchiveItem alloc] initWithRelativeFilePath:relativePath commands:commands mode:mode.unsignedShortValue];
item.itemFilePath = resultPath;
item.sourcePath = operation.fromPath;
item.clonedRelativePath = clonedRelativePath;
[archive addItem:item];
if (operation.changingPermissions) {
if (verbose) {
fprintf(stderr, "\n👮 %s %s (0%o -> 0%o)", VERBOSE_MODIFIED, relativePath.fileSystemRepresentation, operation.oldPermissions.unsignedShortValue, operation.permissions.unsignedShortValue);
}
}
}
if (!deltaOperationsFailed) {
[archive finishEncodingItems];
}
[archive close];
// Clean up operations after the archive has finished encoding
for (CreateBinaryDeltaOperation *operation in deltaOperations) {
NSString *resultPath = operation.resultPath;
if (resultPath != nil) {
unlink(resultPath.fileSystemRepresentation);
}
}
if (deltaOperationsFailed) {
// We already set an error so let's bail
return NO;
}
NSError *archiveError = archive.error;
if (archiveError != nil) {
if (verbose) {
fprintf(stderr, "\n");
}
if (error != NULL) {
*error = archiveError;
}
return NO;
}
NSFileManager *filemgr;
filemgr = [NSFileManager defaultManager];
[filemgr removeItemAtPath: patchFile error: NULL];
if ([filemgr moveItemAtPath: temporaryFile toPath: patchFile error: NULL] != YES)
{
if (verbose) {
fprintf(stderr, "Failed to move temporary file, %s, to %s!\n", [temporaryFile fileSystemRepresentation], [patchFile fileSystemRepresentation]);
}
return NO;
}
if (verbose) {
fprintf(stderr, "\nDone!\n");
}
return YES;
}