-
Notifications
You must be signed in to change notification settings - Fork 0
iOS: add CodePushDiffManifest for parsing diff manifests #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| #import <Foundation/Foundation.h> | ||
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| @interface CodePushPatchedFileEntry : NSObject | ||
|
|
||
| // The only value this client understands at the moment is "bsdiff". | ||
| @property (nonatomic, readonly, copy) NSString *algo; | ||
| // SHA-256 hex of the file's content in the currently installed package. Checked before patching. | ||
| @property (nonatomic, readonly, copy) NSString *baseHash; | ||
| // SHA-256 hex the patched output must match. Checked after patching. | ||
| @property (nonatomic, readonly, copy) NSString *targetHash; | ||
| // Zip-relative path to the patch file, under the reserved patches folder prefix. | ||
| @property (nonatomic, readonly, copy) NSString *patch; | ||
|
|
||
| - (instancetype)initWithAlgo:(NSString *)algo | ||
| baseHash:(NSString *)baseHash | ||
| targetHash:(NSString *)targetHash | ||
| patch:(NSString *)patch; | ||
|
|
||
| @end | ||
|
|
||
| @interface CodePushDiffManifest : NSObject | ||
|
|
||
| // No version field, or version 1: original format, file-by-file patching only. | ||
| // Version 2: adds support for binary diff patching. | ||
| @property (nonatomic, readonly, assign) NSInteger version; | ||
| // Relative paths, from the old package, to delete rather than carry over into the new one. | ||
| @property (nonatomic, readonly, copy) NSArray<NSString *> *deletedFiles; | ||
| // Key: file's relative path in the package being installed. | ||
| @property (nonatomic, readonly, copy) NSDictionary<NSString *, CodePushPatchedFileEntry *> *patchedFiles; | ||
|
|
||
| - (instancetype)initWithVersion:(NSInteger)version | ||
| deletedFiles:(NSArray<NSString *> *)deletedFiles | ||
| patchedFiles:(NSDictionary<NSString *, CodePushPatchedFileEntry *> *)patchedFiles; | ||
|
|
||
| // Parses a diff manifest from its already-deserialized JSON representation. | ||
| // Returns nil and sets *error if a required field is missing or malformed. | ||
| + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error NS_SWIFT_NAME(init(json:)); | ||
|
|
||
| // Turns a relative path from a diff manifest into an absolute path under | ||
| // `folder`. Returns nil and sets *error if the path is malformed, `folder` | ||
| // is not a usable directory, or the path would resolve outside `folder`. | ||
| // | ||
| // Every path in a manifest is untrusted: the manifest and the files it refers | ||
| // to come from the downloaded update, which is unpacked before anything | ||
| // verifies it. | ||
| + (nullable NSString *)resolvePath:(NSString *)relativePath | ||
| withinFolder:(NSString *)folder | ||
| error:(NSError * _Nullable * _Nullable)error | ||
| NS_SWIFT_NAME(resolvePath(_:withinFolder:)); | ||
|
|
||
| @end | ||
|
|
||
| NS_ASSUME_NONNULL_END |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| #import "CodePushDiffManifest.h" | ||
|
|
||
| #import "CodePushErrorUtils.h" | ||
|
|
||
| #import <sys/stat.h> | ||
|
|
||
| static NSError *missingFieldError(NSString *fieldName, NSString *context) | ||
| { | ||
| return [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ is missing required field \"%@\"", context, fieldName]]; | ||
| } | ||
|
|
||
| static NSError *wrongTypedFieldError(NSString *fieldName, NSString *context, id value) | ||
| { | ||
| return [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ field \"%@\" must be a string, but is %@", context, fieldName, NSStringFromClass([value class])]]; | ||
| } | ||
|
|
||
| static BOOL isAbsent(id value) | ||
| { | ||
| return value == nil || [value isKindOfClass:[NSNull class]]; | ||
| } | ||
|
|
||
| // Resolves every symlink in `path`. Returns nil if `path` does not exist or | ||
| // cannot be read. | ||
| static NSString *canonicalPathOfExistingItem(NSString *path) | ||
| { | ||
| char pathBuffer[PATH_MAX]; | ||
| if (![path getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) { | ||
| return nil; | ||
| } | ||
|
|
||
| char resolvedBuffer[PATH_MAX]; | ||
| if (realpath(pathBuffer, resolvedBuffer) == NULL) { | ||
| return nil; | ||
| } | ||
| return [[NSFileManager defaultManager] stringWithFileSystemRepresentation:resolvedBuffer length:strlen(resolvedBuffer)]; | ||
| } | ||
|
|
||
| // Canonicalize the deepest path component that does exist, then re-append | ||
| // the components below it. Returns nil if one of those components is a dangling | ||
| // symlink: it would survive canonicalization as its own path, and a write to it | ||
| // would still follow the link out of the folder. | ||
| static NSString *canonicalPathAllowingMissingComponents(NSString *path) | ||
| { | ||
| NSMutableArray<NSString *> *missingComponents = [NSMutableArray array]; | ||
| NSString *existingAncestor = path; | ||
| NSString *canonicalPath = nil; | ||
|
|
||
| while ((canonicalPath = canonicalPathOfExistingItem(existingAncestor)) == nil) { | ||
| NSString *parent = [existingAncestor stringByDeletingLastPathComponent]; | ||
| if (parent.length == 0 || [parent isEqualToString:existingAncestor]) { | ||
| return nil; | ||
| } | ||
| [missingComponents insertObject:existingAncestor.lastPathComponent atIndex:0]; | ||
| existingAncestor = parent; | ||
| } | ||
|
|
||
| for (NSString *component in missingComponents) { | ||
| // "." refers to the same directory, so appending it verbatim would | ||
| // leave the result unnormalized without changing what it points to. | ||
| if ([component isEqualToString:@"."]) { | ||
| continue; | ||
| } | ||
| canonicalPath = [canonicalPath stringByAppendingPathComponent:component]; | ||
|
|
||
| char componentBuffer[PATH_MAX]; | ||
| if (![canonicalPath getFileSystemRepresentation:componentBuffer maxLength:sizeof(componentBuffer)]) { | ||
| return nil; | ||
| } | ||
|
|
||
| struct stat fileInfo; | ||
| if (lstat(componentBuffer, &fileInfo) == 0 && S_ISLNK(fileInfo.st_mode)) { | ||
| return nil; | ||
| } | ||
| } | ||
| return canonicalPath; | ||
| } | ||
|
|
||
| @implementation CodePushPatchedFileEntry | ||
|
|
||
| - (instancetype)initWithAlgo:(NSString *)algo | ||
| baseHash:(NSString *)baseHash | ||
| targetHash:(NSString *)targetHash | ||
| patch:(NSString *)patch | ||
| { | ||
| self = [super init]; | ||
| if (self) { | ||
| _algo = [algo copy]; | ||
| _baseHash = [baseHash copy]; | ||
| _targetHash = [targetHash copy]; | ||
| _patch = [patch copy]; | ||
| } | ||
| return self; | ||
| } | ||
|
|
||
| @end | ||
|
|
||
| @implementation CodePushDiffManifest | ||
|
|
||
| - (instancetype)initWithVersion:(NSInteger)version | ||
| deletedFiles:(NSArray<NSString *> *)deletedFiles | ||
| patchedFiles:(NSDictionary<NSString *, CodePushPatchedFileEntry *> *)patchedFiles | ||
| { | ||
| self = [super init]; | ||
| if (self) { | ||
| _version = version; | ||
| _deletedFiles = [deletedFiles copy]; | ||
| _patchedFiles = [patchedFiles copy]; | ||
| } | ||
| return self; | ||
| } | ||
|
|
||
| + (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error | ||
| { | ||
| if (![json isKindOfClass:[NSDictionary class]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest must be a JSON object, but is %@", NSStringFromClass([json class])]]; | ||
| return nil; | ||
| } | ||
|
|
||
| // A version we cannot read is a hard failure: silently treating it as 1 | ||
| // would skip every patch and install the old bytes under the new hash. | ||
| id versionValue = json[@"version"]; | ||
| NSInteger version = 1; | ||
| if (!isAbsent(versionValue)) { | ||
| BOOL isBoolean = versionValue == (id)kCFBooleanTrue || versionValue == (id)kCFBooleanFalse; | ||
| double versionDouble = [versionValue isKindOfClass:[NSNumber class]] ? [versionValue doubleValue] : 0; | ||
| if (![versionValue isKindOfClass:[NSNumber class]] || isBoolean || versionDouble != trunc(versionDouble)) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"version\" must be an integer, but is \"%@\"", versionValue]]; | ||
| return nil; | ||
| } | ||
| version = [versionValue integerValue]; | ||
| } | ||
|
ofalvai marked this conversation as resolved.
|
||
|
|
||
| id deletedFilesJSON = json[@"deletedFiles"]; | ||
| NSMutableArray<NSString *> *deletedFiles = [NSMutableArray array]; | ||
| if (!isAbsent(deletedFilesJSON)) { | ||
| if (![deletedFilesJSON isKindOfClass:[NSArray class]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"deletedFiles\" must be an array, but is %@", NSStringFromClass([deletedFilesJSON class])]]; | ||
| return nil; | ||
| } | ||
| for (id deletedFileName in (NSArray *)deletedFilesJSON) { | ||
| if (![deletedFileName isKindOfClass:[NSString class]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"deletedFiles\" must hold strings, but holds \"%@\"", deletedFileName]]; | ||
| return nil; | ||
| } | ||
| [deletedFiles addObject:deletedFileName]; | ||
| } | ||
| } | ||
|
|
||
| id patchedFilesJSON = json[@"patchedFiles"]; | ||
| NSMutableDictionary<NSString *, CodePushPatchedFileEntry *> *patchedFiles = [NSMutableDictionary dictionary]; | ||
| if (!isAbsent(patchedFilesJSON)) { | ||
| if (![patchedFilesJSON isKindOfClass:[NSDictionary class]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"patchedFiles\" must be an object, but is %@", NSStringFromClass([patchedFilesJSON class])]]; | ||
| return nil; | ||
| } | ||
| for (NSString *relativePath in (NSDictionary *)patchedFilesJSON) { | ||
| NSString *context = [NSString stringWithFormat:@"patchedFiles[\"%@\"]", relativePath]; | ||
|
|
||
| id entryJSON = patchedFilesJSON[relativePath]; | ||
| if (![entryJSON isKindOfClass:[NSDictionary class]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ must be an object, but is %@", context, NSStringFromClass([entryJSON class])]]; | ||
| return nil; | ||
| } | ||
|
|
||
| for (NSString *fieldName in @[@"algo", @"baseHash", @"targetHash", @"patch"]) { | ||
| id fieldValue = entryJSON[fieldName]; | ||
| if (![fieldValue isKindOfClass:[NSString class]]) { | ||
| if (error) *error = isAbsent(fieldValue) ? missingFieldError(fieldName, context) : wrongTypedFieldError(fieldName, context, fieldValue); | ||
| return nil; | ||
| } | ||
|
ofalvai marked this conversation as resolved.
|
||
| } | ||
|
|
||
| patchedFiles[relativePath] = [[CodePushPatchedFileEntry alloc] initWithAlgo:entryJSON[@"algo"] | ||
| baseHash:entryJSON[@"baseHash"] | ||
| targetHash:entryJSON[@"targetHash"] | ||
| patch:entryJSON[@"patch"]]; | ||
| } | ||
| } | ||
|
|
||
| // Only version 2 defines file patching. | ||
| // A manifest of any other version that lists patched files is malformed, | ||
| // and applying none of them would leave the old bytes behind. | ||
| if (version != 2 && patchedFiles.count > 0) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest declares version %ld but lists %lu patchedFiles, which require version 2", (long)version, (unsigned long)patchedFiles.count]]; | ||
| return nil; | ||
| } | ||
|
|
||
| return [[CodePushDiffManifest alloc] initWithVersion:version | ||
| deletedFiles:deletedFiles | ||
| patchedFiles:patchedFiles]; | ||
| } | ||
|
|
||
| + (nullable NSString *)resolvePath:(NSString *)relativePath | ||
| withinFolder:(NSString *)folder | ||
| error:(NSError **)error | ||
| { | ||
| if (relativePath.length == 0) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path is empty"]; | ||
| return nil; | ||
| } | ||
| if (relativePath.isAbsolutePath) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; | ||
| return nil; | ||
| } | ||
| // A path the file system cannot represent - an embedded NUL, or one longer | ||
| // than PATH_MAX - never reaches a syscall from here. | ||
| char pathBuffer[PATH_MAX]; | ||
| if (![relativePath getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path cannot be represented in the file system"]; | ||
| return nil; | ||
| } | ||
|
miklosboros marked this conversation as resolved.
|
||
| for (NSString *component in relativePath.pathComponents) { | ||
| if ([component isEqualToString:@".."]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; | ||
| return nil; | ||
| } | ||
| } | ||
|
|
||
| NSString *canonicalFolder = canonicalPathOfExistingItem(folder); | ||
| if (canonicalFolder == nil) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"base folder does not exist or is not accessible"]; | ||
| return nil; | ||
| } | ||
|
|
||
| NSString *resolved = canonicalPathAllowingMissingComponents([canonicalFolder stringByAppendingPathComponent:relativePath]); | ||
| if (resolved == nil) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; | ||
| return nil; | ||
| } | ||
| // Callers treat the result as a file inside the folder, so the folder | ||
| // itself (e.g. from "." or a symlink resolving back to it) must not pass. | ||
| if (![resolved hasPrefix:[canonicalFolder stringByAppendingString:@"/"]]) { | ||
| if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; | ||
| return nil; | ||
| } | ||
| return resolved; | ||
| } | ||
|
|
||
| @end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.