feat: add new Squirrel.Mac bundle installation method behind flag (#33470)

This commit is contained in:
Samuel Attard 2022-03-29 14:47:34 -07:00 committed by GitHub
parent 4c988a5a24
commit 479f652f90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 190 additions and 1 deletions

View file

@ -1,3 +1,4 @@
build_add_gn_config.patch build_add_gn_config.patch
fix_ensure_that_self_is_retained_until_the_racsignal_is_complete.patch fix_ensure_that_self_is_retained_until_the_racsignal_is_complete.patch
fix_use_kseccschecknestedcode_kseccsstrictvalidate_in_the_sec.patch fix_use_kseccschecknestedcode_kseccsstrictvalidate_in_the_sec.patch
feat_add_new_squirrel_mac_bundle_installation_method_behind_flag.patch

View file

@ -0,0 +1,128 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Samuel Attard <samuel.r.attard@gmail.com>
Date: Mon, 28 Mar 2022 02:43:18 -0700
Subject: feat: add new Squirrel.Mac bundle installation method behind flag
The 'SquirrelMacEnableDirectContentsWrite' user default in your apps defaults suite
can be used to control this new installation method. It is designed to remove the
requirement that the updating process have write access to its parent directory.
With this feature enabled the updating process only needs write access to it's own
.app bundle folder, not the owning /Applications folder. This should allow more
non-admin users to update applications when appropriately granted group based permissions.
E.g. 775 and chown :staff
diff --git a/Squirrel/SQRLInstaller.m b/Squirrel/SQRLInstaller.m
index 7dd98ddee4ae0f4e01fd7aaa3486083bff7d0da1..c1f328fa8c3689218ef260347cb8f9d30b789efe 100644
--- a/Squirrel/SQRLInstaller.m
+++ b/Squirrel/SQRLInstaller.m
@@ -249,6 +249,7 @@ - (RACSignal *)acquireTargetBundleURLForRequest:(SQRLShipItRequest *)request {
] reduce:^(NSURL *directoryURL, SQRLCodeSignature *codeSignature) {
NSURL *targetBundleURL = request.targetBundleURL;
NSURL *newBundleURL = [directoryURL URLByAppendingPathComponent:targetBundleURL.lastPathComponent];
+ [NSFileManager.defaultManager createDirectoryAtURL:newBundleURL withIntermediateDirectories:FALSE attributes:nil error:nil];
return [[SQRLInstallerOwnedBundle alloc] initWithOriginalURL:request.targetBundleURL temporaryURL:newBundleURL codeSignature:codeSignature];
}]
@@ -481,10 +482,50 @@ - (RACSignal *)installItemToURL:(NSURL *)targetURL fromURL:(NSURL *)sourceURL {
NSParameterAssert(targetURL != nil);
NSParameterAssert(sourceURL != nil);
+ NSLog(@"Moving bundle from %@ to %@", sourceURL, targetURL);
+
+ // If both the sourceURL and the targetURL exist we can try to skip a permissions check
+ // by moving Thing.app/Contents directly. This allows us to update applications without
+ // permission to write files into the parent directory of Thing.app
+ //
+ // There is no known case where these directories don't exist but in order to handle
+ // edge cases / race conditions we'll handle it anyway.
+ //
+ // This exists check is non-atomic with the rename call below but that's OK
+ BOOL canRenameContentsDirectly = FALSE;
+ // For now while this is tested at scale this new option is behind a user default, this
+ // can be set by applications wishing to test this feature at runtime. If it causes issues
+ // it can be opted out by individual users by setting this key to false explicitly.
+ // Once this has bene tested at scale it will become the default for all Squirrel.Mac
+ // users.
+ NSUserDefaults *defaults = [[NSUserDefaults alloc] init];
+ [defaults addSuiteNamed:_applicationIdentifier];
+ // In cases where this code is being executed under the ShipIt executable it's running
+ // under an application identifier equal to {parent_identifier}.ShipIt
+ // In this case we need to use the true parent identifier too as that is 99% of the time
+ // where the key will be set.
+ if ([_applicationIdentifier hasSuffix:@".ShipIt"]) {
+ [defaults addSuiteNamed:[_applicationIdentifier substringToIndex:[_applicationIdentifier length] - 7]];
+ }
+
+ if ([defaults boolForKey:@"SquirrelMacEnableDirectContentsWrite"]) {
+ canRenameContentsDirectly = [NSFileManager.defaultManager fileExistsAtPath:targetURL.path] && [NSFileManager.defaultManager fileExistsAtPath:sourceURL.path];
+
+ if (canRenameContentsDirectly) {
+ NSLog(@"Moving bundles via 'Contents' folder rename");
+ } else {
+ NSLog(@"Moving bundles directly as one of source / target does not exist. This is unexpected.");
+ }
+ } else {
+ NSLog(@"Moving bundles directly as SquirrelMacEnableDirectContentsWrite is disabled for app: %@", _applicationIdentifier);
+ }
+ NSURL *targetContentsURL = canRenameContentsDirectly ? [targetURL URLByAppendingPathComponent:@"Contents"] : targetURL;
+ NSURL *sourceContentsURL = canRenameContentsDirectly ? [sourceURL URLByAppendingPathComponent:@"Contents"] : sourceURL;
+
return [[[[RACSignal
defer:^{
// rename() is atomic, NSFileManager sucks.
- if (rename(sourceURL.path.fileSystemRepresentation, targetURL.path.fileSystemRepresentation) == 0) {
+ if (rename(sourceContentsURL.path.fileSystemRepresentation, targetContentsURL.path.fileSystemRepresentation) == 0) {
return [RACSignal empty];
} else {
int code = errno;
@@ -497,24 +538,24 @@ - (RACSignal *)installItemToURL:(NSURL *)targetURL fromURL:(NSURL *)sourceURL {
}
}]
doCompleted:^{
- NSLog(@"Moved bundle from %@ to %@", sourceURL, targetURL);
+ NSLog(@"Moved bundle contents from %@ to %@", sourceContentsURL, targetContentsURL);
}]
catch:^(NSError *error) {
if (![error.domain isEqual:NSPOSIXErrorDomain] || error.code != EXDEV) return [RACSignal error:error];
// If the locations lie on two different volumes, remove the
// destination by hand, then perform a move.
- [NSFileManager.defaultManager removeItemAtURL:targetURL error:NULL];
+ [NSFileManager.defaultManager removeItemAtURL:targetContentsURL error:NULL];
- if ([NSFileManager.defaultManager moveItemAtURL:sourceURL toURL:targetURL error:&error]) {
- NSLog(@"Moved bundle across volumes from %@ to %@", sourceURL, targetURL);
+ if ([NSFileManager.defaultManager moveItemAtURL:sourceContentsURL toURL:targetContentsURL error:&error]) {
+ NSLog(@"Moved bundle contents across volumes from %@ to %@", sourceContentsURL, targetContentsURL);
return [RACSignal empty];
} else {
- NSString *description = [NSString stringWithFormat:NSLocalizedString(@"Couldn't move bundle %@ across volumes to %@", nil), sourceURL, targetURL];
+ NSString *description = [NSString stringWithFormat:NSLocalizedString(@"Couldn't move bundle contents %@ across volumes to %@", nil), sourceContentsURL, targetContentsURL];
return [RACSignal error:[self errorByAddingDescription:description code:SQRLInstallerErrorMovingAcrossVolumes toError:error]];
}
}]
- setNameWithFormat:@"%@ -installItemAtURL: %@ fromURL: %@", self, targetURL, sourceURL];
+ setNameWithFormat:@"%@ -installItemAtURL: %@ fromURL: %@", self, targetContentsURL, sourceContentsURL];
}
#pragma mark Quarantine Bit Removal
diff --git a/Squirrel/SQRLUpdater.m b/Squirrel/SQRLUpdater.m
index c81c820d61da3c7d1cfd2c516147c954a5773a0c..4c703159a2bb0239b7d4e1793a985b5ec2edcfa9 100644
--- a/Squirrel/SQRLUpdater.m
+++ b/Squirrel/SQRLUpdater.m
@@ -329,7 +329,12 @@ - (id)initWithUpdateRequest:(NSURLRequest *)updateRequest requestForDownload:(SQ
BOOL targetWritable = [self canWriteToURL:targetURL];
BOOL parentWritable = [self canWriteToURL:targetURL.URLByDeletingLastPathComponent];
- return [SQRLShipItLauncher launchPrivileged:!targetWritable || !parentWritable];
+ BOOL launchPrivileged = !targetWritable || !parentWritable;
+ if ([[NSUserDefaults standardUserDefaults] boolForKey:@"SquirrelMacEnableDirectContentsWrite"]) {
+ // If SquirrelMacEnableDirectContentsWrite is enabled we don't care if the parent directory is writeable or not
+ BOOL launchPrivileged = !targetWritable;
+ }
+ return [SQRLShipItLauncher launchPrivileged:launchPrivileged];
}]
replayLazily]
setNameWithFormat:@"shipItLauncher"];

View file

@ -7,6 +7,8 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { AddressInfo } from 'net'; import { AddressInfo } from 'net';
import { ifdescribe, ifit } from './spec-helpers'; import { ifdescribe, ifit } from './spec-helpers';
import * as uuid from 'uuid';
import { systemPreferences } from 'electron';
const features = process._linkedBinding('electron_common_features'); const features = process._linkedBinding('electron_common_features');
@ -132,7 +134,7 @@ ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch ===
await signApp(secondAppPath); await signApp(secondAppPath);
await mutateAppPostSign?.mutate(secondAppPath); await mutateAppPostSign?.mutate(secondAppPath);
updateZipPath = path.resolve(dir, 'update.zip'); updateZipPath = path.resolve(dir, 'update.zip');
await spawn('zip', ['-r', '--symlinks', updateZipPath, './'], { await spawn('zip', ['-0', '-r', '--symlinks', updateZipPath, './'], {
cwd: dir cwd: dir
}); });
}, false); }, false);
@ -321,6 +323,64 @@ ifdescribe(process.platform === 'darwin' && !(process.env.CI && process.arch ===
}); });
}); });
describe('with SquirrelMacEnableDirectContentsWrite enabled', () => {
let previousValue: any;
beforeEach(() => {
previousValue = systemPreferences.getUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean');
systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', true as any);
});
afterEach(() => {
systemPreferences.setUserDefault('SquirrelMacEnableDirectContentsWrite', 'boolean', previousValue as any);
});
it('should hit the download endpoint when an update is available and update successfully when the zip is provided leaving the parent directory untouched', async () => {
await withUpdatableApp({
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update'
}, async (appPath, updateZipPath) => {
const randomID = uuid.v4();
cp.spawnSync('xattr', ['-w', 'spec-id', randomID, appPath]);
server.get('/update-file', (req, res) => {
res.download(updateZipPath);
});
server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: (new Date()).toString()
});
});
const relaunchPromise = new Promise<void>((resolve) => {
server.get('/update-check/updated/:version', (req, res) => {
res.status(204).send();
resolve();
});
});
const launchResult = await launchApp(appPath, [`http://localhost:${port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).to.have.property('code', 0);
expect(launchResult.out).to.include('Update Downloaded');
expect(requests).to.have.lengthOf(2);
expect(requests[0]).to.have.property('url', '/update-check');
expect(requests[1]).to.have.property('url', '/update-file');
expect(requests[0].header('user-agent')).to.include('Electron/');
expect(requests[1].header('user-agent')).to.include('Electron/');
});
await relaunchPromise;
expect(requests).to.have.lengthOf(3);
expect(requests[2].url).to.equal('/update-check/updated/2.0.0');
expect(requests[2].header('user-agent')).to.include('Electron/');
const result = cp.spawnSync('xattr', ['-l', appPath]);
expect(result.stdout.toString()).to.include(`spec-id: ${randomID}`);
});
});
});
it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => { it('should hit the download endpoint when an update is available and fail when the zip signature is invalid', async () => {
await withUpdatableApp({ await withUpdatableApp({
nextVersion: '2.0.0', nextVersion: '2.0.0',