rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1"""Release boundary checks: configuration identity, signing grants and feed order."""
2import base64
3import hashlib
4import json
5from pathlib import Path
6import plistlib
7import subprocess
8import sys
9import tempfile
10import unittest
11import zipfile
12from unittest.mock import patch
13
14sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15from mac_updates import configure, read_configuration, sign_bundle
16from publish_mac_update import check_build, publish, verify_download
17
18
19class 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
110class 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
292if __name__ == '__main__':
293 unittest.main()
294