| 1 | """Release boundary checks: configuration identity, signing grants and feed order.""" |
| 2 | import base64 |
| 3 | import hashlib |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import plistlib |
| 7 | import subprocess |
| 8 | import sys |
| 9 | import tempfile |
| 10 | import unittest |
| 11 | import zipfile |
| 12 | from unittest.mock import patch |
| 13 | |
| 14 | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| 15 | from mac_updates import configure, read_configuration, sign_bundle |
| 16 | from publish_mac_update import check_build, publish, verify_download |
| 17 | |
| 18 | |
| 19 | class MacUpdatesTests(unittest.TestCase): |
| 20 | def setUp(self): |
| 21 | self.temporary = tempfile.TemporaryDirectory() |
| 22 | self.addCleanup(self.temporary.cleanup) |
| 23 | self.root = Path(self.temporary.name) |
| 24 | self.app = self.root / 'Meet.app' |
| 25 | (self.app / 'Contents').mkdir(parents=True) |
| 26 | self.info = self.app / 'Contents/Info.plist' |
| 27 | self.info.write_bytes(plistlib.dumps(dict(CFBundleIdentifier='net.amadan.oficina.meet', NSCameraUsageDescription='Camera', NSMicrophoneUsageDescription='Microphone'))) |
| 28 | self.config = self.root / 'updates.json' |
| 29 | self.values = dict(bundle_id='net.amadan.oficina.meet', feed_url='https://raw.amadan.net/oficina/native-releases/main/meet/appcast.xml', public_ed_key=base64.b64encode(bytes(32)).decode(), keychain_account='oficina-test') |
| 30 | self.config.write_text(json.dumps(self.values)) |
| 31 | |
| 32 | def test_configuration_rejects_cross_app_and_insecure_feeds(self): |
| 33 | for field, value in [('bundle_id', 'net.amadan.oficina.docs'), ('feed_url', 'http://example.com/feed'), ('feed_url', 'https://user:secret@example.com/feed'), ('public_ed_key', base64.b64encode(bytes(31)).decode())]: |
| 34 | with self.subTest(field=field, value=value): |
| 35 | self.config.write_text(json.dumps(self.values | {field: value})) |
| 36 | with self.assertRaises(ValueError): |
| 37 | configure(self.app, self.config) |
| 38 | |
| 39 | def test_dev_build_removes_feed_without_forcing_preferences(self): |
| 40 | configure(self.app, self.config, '1.2.3', '2') |
| 41 | info = plistlib.loads(self.info.read_bytes()) |
| 42 | self.assertTrue(info['SUVerifyUpdateBeforeExtraction']) |
| 43 | self.assertFalse(info['SUAllowsAutomaticUpdates']) |
| 44 | self.assertNotIn('SUEnableAutomaticChecks', info) |
| 45 | configure(self.app) |
| 46 | self.assertNotIn('SUFeedURL', plistlib.loads(self.info.read_bytes())) |
| 47 | |
| 48 | def test_automatic_install_mode_sets_defaults_a_person_can_override(self): |
| 49 | self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| 50 | configure(self.app, self.config, '1.2.3', '2') |
| 51 | info = plistlib.loads(self.info.read_bytes()) |
| 52 | # Info.plist values are defaults: Sparkle reads a stored preference first. |
| 53 | self.assertTrue(info['SUAllowsAutomaticUpdates']) |
| 54 | self.assertTrue(info['SUEnableAutomaticChecks']) |
| 55 | self.assertTrue(info['SUAutomaticallyUpdate']) |
| 56 | self.assertEqual(info['SUScheduledCheckInterval'], 86400) |
| 57 | # Rebuilding in the default mode leaves none of them behind. |
| 58 | self.config.write_text(json.dumps(self.values)) |
| 59 | configure(self.app, self.config) |
| 60 | info = plistlib.loads(self.info.read_bytes()) |
| 61 | self.assertFalse(info['SUAllowsAutomaticUpdates']) |
| 62 | for key in ['SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', 'SUScheduledCheckInterval']: |
| 63 | self.assertNotIn(key, info) |
| 64 | |
| 65 | def test_unknown_install_mode_is_rejected(self): |
| 66 | self.config.write_text(json.dumps(self.values | {'install': 'silent'})) |
| 67 | with self.assertRaises(ValueError): |
| 68 | configure(self.app, self.config) |
| 69 | |
| 70 | def test_signing_keeps_device_grants_on_host_only(self): |
| 71 | current = self.app / 'Contents/Frameworks/Sparkle.framework/Versions/Current' |
| 72 | for path in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: |
| 73 | (current / path).mkdir(parents=True) |
| 74 | calls = [] |
| 75 | entitlements = [] |
| 76 | def capture(command, **kwargs): |
| 77 | calls.append(command) |
| 78 | if '--entitlements' in command: |
| 79 | entitlements.append(plistlib.loads(Path(command[command.index('--entitlements') + 1]).read_bytes())) |
| 80 | with patch('mac_updates.subprocess.run', side_effect=capture): |
| 81 | sign_bundle(self.app, 'Developer ID Application: Test') |
| 82 | self.assertEqual(entitlements, [{'com.apple.security.device.camera': True, 'com.apple.security.device.audio-input': True}]) |
| 83 | self.assertEqual(calls[-2][-1], str(self.app)) |
| 84 | self.assertEqual(calls[-3][-1], str(self.app / 'Contents/Frameworks/Sparkle.framework')) |
| 85 | self.assertIn('--preserve-metadata=entitlements', calls[0]) |
| 86 | self.assertIn('runtime', calls[-2]) |
| 87 | self.assertEqual(calls[-1][1:5], ['--verify', '--deep', '--strict', str(self.app)]) |
| 88 | |
| 89 | def test_rejects_non_increasing_builds_in_current_and_old_appcast_formats(self): |
| 90 | feed = self.root / 'appcast.xml' |
| 91 | for version in ['<sparkle:version>7</sparkle:version>', '<enclosure sparkle:version="7"/>']: |
| 92 | feed.write_text('<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item>' + version + '</item></channel></rss>') |
| 93 | for build in ['6', '7', 'invalid']: |
| 94 | with self.assertRaises(ValueError): |
| 95 | check_build(feed, build) |
| 96 | check_build(feed, '8') |
| 97 | |
| 98 | def test_public_archive_must_match_exact_signed_bytes(self): |
| 99 | import hashlib |
| 100 | import io |
| 101 | def response(*args, **kwargs): |
| 102 | stream = io.BytesIO(b'tampered') |
| 103 | stream.url = 'https://raw.amadan.net/oficina/native-releases/commit/archive.zip' |
| 104 | return stream |
| 105 | with patch('publish_mac_update.urlopen', side_effect=response), patch('publish_mac_update.time.sleep'): |
| 106 | with self.assertRaises(ValueError): |
| 107 | verify_download('https://raw.amadan.net/archive.zip', 8, hashlib.sha256(b'original').hexdigest()) |
| 108 | |
| 109 | |
| 110 | class PublicationBoundaryTests(unittest.TestCase): |
| 111 | """Exercise publication failures without Git, Keychain, signing or network access.""" |
| 112 | |
| 113 | def setUp(self): |
| 114 | temporary = tempfile.TemporaryDirectory(prefix='sparkle-publication-test-') |
| 115 | self.addCleanup(temporary.cleanup) |
| 116 | self.root = Path(temporary.name) |
| 117 | self.release = self.root / 'release' |
| 118 | self.release.mkdir() |
| 119 | self.repo = self.root / 'repository' |
| 120 | (self.repo / 'meet').mkdir(parents=True) |
| 121 | self.feed = self.repo / 'meet/appcast.xml' |
| 122 | self.original_feed = '<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:version>2</sparkle:version></item></channel></rss>' |
| 123 | self.feed.write_text(self.original_feed) |
| 124 | self.config = self.root / 'config.json' |
| 125 | self.values = dict(bundle_id='net.amadan.oficina.meet', feed_url='https://raw.amadan.net/oficina/native-releases/main/meet/appcast.xml', public_ed_key=base64.b64encode(bytes(32)).decode(), keychain_account='test-only') |
| 126 | self.config.write_text(json.dumps(self.values)) |
| 127 | self.archive = self.release / 'meet-3.zip' |
| 128 | self.write_archive(SUAllowsAutomaticUpdates=False) |
| 129 | self.manifest = dict(bundle_id=self.values['bundle_id'], feed_url=self.values['feed_url'], build='3', version='1.0', archive=self.archive.name, length=self.archive.stat().st_size, sha256=hashlib.sha256(self.archive.read_bytes()).hexdigest(), signature='test-signature') |
| 130 | self.write_manifest() |
| 131 | self.events = [] |
| 132 | self.generated_build = '3' |
| 133 | self.download_error = None |
| 134 | self.upload_returncode = 0 |
| 135 | self.existing_tag = '' |
| 136 | self.downloads = [] |
| 137 | self.addCleanup(patch.stopall) |
| 138 | patch('publish_mac_update.git', side_effect=self.git).start() |
| 139 | patch('publish_mac_update.subprocess.check_output', return_value=self.values['public_ed_key']).start() |
| 140 | patch('publish_mac_update.subprocess.run', side_effect=self.tool).start() |
| 141 | patch('publish_mac_update.verify_download', side_effect=self.download).start() |
| 142 | |
| 143 | def write_archive(self, **install): |
| 144 | info = dict(CFBundleIdentifier=self.values['bundle_id'], SUFeedURL=self.values['feed_url'], SUPublicEDKey=self.values['public_ed_key'], CFBundleVersion='3', CFBundleShortVersionString='1.0', **install) |
| 145 | with zipfile.ZipFile(self.archive, 'w') as zipped: |
| 146 | zipped.writestr('Meet.app/Contents/Info.plist', plistlib.dumps(info)) |
| 147 | |
| 148 | def write_manifest(self): |
| 149 | (self.release / 'release.json').write_text(json.dumps(self.manifest)) |
| 150 | |
| 151 | def git(self, repo, *args): |
| 152 | self.events.append(('git', *args)) |
| 153 | if args[:3] == ('remote', 'get-url', '--push'): |
| 154 | return 'https://amadan.net/oficina/native-releases' |
| 155 | if args[0] == 'branch': |
| 156 | return 'main' |
| 157 | if args[0] == 'rev-parse': |
| 158 | return 'a' * 40 |
| 159 | if args[:2] == ('tag', '--list'): |
| 160 | return self.existing_tag |
| 161 | return '' |
| 162 | |
| 163 | def tool(self, command, **kwargs): |
| 164 | tool = Path(command[0]).name |
| 165 | self.events.append((tool, *command[1:3]) if tool == 'amadan' else (tool,)) |
| 166 | if tool == 'amadan': |
| 167 | return subprocess.CompletedProcess(command, self.upload_returncode) |
| 168 | if tool != 'generate_appcast': |
| 169 | return |
| 170 | prefix = command[command.index('--download-url-prefix') + 1] |
| 171 | output = Path(command[command.index('-o') + 1]) |
| 172 | output.write_text(f'<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:version>{self.generated_build}</sparkle:version><enclosure url="{prefix}{self.archive.name}" sparkle:edSignature="test-signature" length="{self.manifest["length"]}"/></item></channel></rss>') |
| 173 | |
| 174 | def download(self, *args): |
| 175 | self.downloads.append(args) |
| 176 | self.events.append(('verify_download',)) |
| 177 | if self.download_error: |
| 178 | raise self.download_error |
| 179 | |
| 180 | def publish(self, enabled=True): |
| 181 | return publish(self.release, self.config, self.repo, self.root / 'tools', enabled) |
| 182 | |
| 183 | def pushes(self): |
| 184 | return [event for event in self.events if event[:2] == ('git', 'push')] |
| 185 | |
| 186 | def feed_pushes(self): |
| 187 | return [event for event in self.pushes() if event[-1] == 'HEAD:refs/heads/main'] |
| 188 | |
| 189 | def test_manifest_cannot_claim_a_newer_build_than_the_signed_archive(self): |
| 190 | self.manifest['build'] = '4' |
| 191 | self.write_manifest() |
| 192 | with self.assertRaisesRegex(ValueError, 'metadata does not match'): |
| 193 | self.publish() |
| 194 | self.assertEqual(self.events, []) |
| 195 | self.assertFalse((self.repo / 'meet/releases').exists()) |
| 196 | |
| 197 | def test_archive_built_for_another_install_mode_is_rejected(self): |
| 198 | # A quiet app's feed must not ship a build that asks, nor the reverse. |
| 199 | self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| 200 | with self.assertRaisesRegex(ValueError, 'metadata does not match'): |
| 201 | self.publish() |
| 202 | self.assertEqual(self.events, []) |
| 203 | |
| 204 | def test_automatic_install_mode_archive_publishes(self): |
| 205 | self.config.write_text(json.dumps(self.values | {'install': 'automatic'})) |
| 206 | self.write_archive(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True, SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400) |
| 207 | self.manifest.update(length=self.archive.stat().st_size, sha256=hashlib.sha256(self.archive.read_bytes()).hexdigest()) |
| 208 | self.write_manifest() |
| 209 | self.assertEqual(self.publish(enabled=False)['build'], '3') |
| 210 | |
| 211 | def test_changed_archive_is_rejected_before_publication(self): |
| 212 | with self.archive.open('ab') as archive: |
| 213 | archive.write(b'changed after signing') |
| 214 | with self.assertRaisesRegex(ValueError, 'archive changed'): |
| 215 | self.publish() |
| 216 | self.assertEqual(self.events, []) |
| 217 | |
| 218 | def test_plan_does_not_commit_push_or_copy_artifacts(self): |
| 219 | plan = self.publish(enabled=False) |
| 220 | self.assertEqual(plan['build'], '3') |
| 221 | self.assertFalse((self.repo / 'meet/releases').exists()) |
| 222 | self.assertEqual(self.feed.read_text(), self.original_feed) |
| 223 | self.assertFalse(any(event[0] == 'git' and event[1] in ('fetch', 'tag', 'add', 'commit', 'push') for event in self.events)) |
| 224 | self.assertNotIn(('amadan', 'release', 'create'), self.events) |
| 225 | self.assertEqual(plan['download'], 'https://amadan.net/oficina/native-releases/-/releases/meet-3/meet-3.zip') |
| 226 | |
| 227 | def test_failed_public_download_leaves_existing_feed_untouched(self): |
| 228 | self.download_error = ValueError('download checksum mismatch') |
| 229 | with self.assertRaisesRegex(ValueError, 'checksum mismatch'): |
| 230 | self.publish() |
| 231 | self.assertEqual(self.feed_pushes(), []) |
| 232 | self.assertNotIn(('generate_appcast',), self.events) |
| 233 | self.assertEqual(self.feed.read_text(), self.original_feed) |
| 234 | |
| 235 | def test_generated_feed_with_wrong_version_is_never_published(self): |
| 236 | self.generated_build = '4' |
| 237 | with self.assertRaisesRegex(ValueError, 'build does not match'): |
| 238 | self.publish() |
| 239 | self.assertEqual(self.feed_pushes(), []) |
| 240 | self.assertEqual(self.feed.read_text(), self.original_feed) |
| 241 | |
| 242 | def test_archive_is_publicly_verified_before_feed_publication(self): |
| 243 | self.publish() |
| 244 | tag = self.events.index(('git', 'push', 'origin', 'refs/tags/meet-3')) |
| 245 | uploaded = self.events.index(('amadan', 'release', 'create')) |
| 246 | verified = self.events.index(('verify_download',)) |
| 247 | generated = self.events.index(('generate_appcast',)) |
| 248 | feed = self.events.index(('git', 'push', 'origin', 'HEAD:refs/heads/main')) |
| 249 | self.assertLess(tag, uploaded) |
| 250 | self.assertLess(uploaded, verified) |
| 251 | self.assertLess(verified, generated) |
| 252 | self.assertLess(generated, feed) |
| 253 | |
| 254 | def test_archive_is_never_committed_to_git(self): |
| 255 | # native-releases#1: zips in Git history took amadan.net down. |
| 256 | self.publish() |
| 257 | added = [event for event in self.events if event[:2] == ('git', 'add')] |
| 258 | self.assertEqual(added, [('git', 'add', '--', 'meet/appcast.xml')]) |
| 259 | self.assertFalse((self.repo / 'meet/releases').exists()) |
| 260 | |
| 261 | def test_verification_checks_the_release_asset_against_the_signed_bytes(self): |
| 262 | self.publish() |
| 263 | self.assertEqual(self.downloads, [('https://amadan.net/oficina/native-releases/-/releases/meet-3/meet-3.zip', self.manifest['length'], self.manifest['sha256'])]) |
| 264 | |
| 265 | def test_first_release_of_an_app_creates_its_feed(self): |
| 266 | self.feed.unlink() |
| 267 | self.feed.parent.rmdir() |
| 268 | self.publish() |
| 269 | self.assertIn('<sparkle:version>3</sparkle:version>', self.feed.read_text()) |
| 270 | |
| 271 | def test_rerun_reuses_the_tag_an_earlier_run_pushed(self): |
| 272 | self.existing_tag = 'meet-3' |
| 273 | self.publish() |
| 274 | self.assertFalse(any(event[:2] == ('git', 'tag') and event[2] != '--list' for event in self.events)) |
| 275 | self.assertIn(('git', 'push', 'origin', 'refs/tags/meet-3'), self.events) |
| 276 | |
| 277 | def test_rerun_accepts_refused_upload_only_when_asset_matches(self): |
| 278 | # An asset cannot be replaced, so a rerun's upload fails; the public bytes decide. |
| 279 | self.upload_returncode = 1 |
| 280 | self.assertEqual(self.publish()['build'], '3') |
| 281 | self.assertEqual(len(self.feed_pushes()), 1) |
| 282 | |
| 283 | def test_failed_upload_without_a_matching_asset_publishes_nothing(self): |
| 284 | self.upload_returncode = 1 |
| 285 | self.download_error = OSError('HTTP Error 404: Not Found') |
| 286 | with self.assertRaises(OSError): |
| 287 | self.publish() |
| 288 | self.assertEqual(self.feed_pushes(), []) |
| 289 | self.assertEqual(self.feed.read_text(), self.original_feed) |
| 290 | |
| 291 | |
| 292 | if __name__ == '__main__': |
| 293 | unittest.main() |
| 294 | |