Merge ~cjwatson/turnip:non-ascii-ref-permissions into turnip:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 75e777e8ff5fbbc79fd259d706539b6c8473f6b1
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/turnip:non-ascii-ref-permissions
Merge into: turnip:master
Prerequisite: ~cjwatson/turnip:hookrpc-test
Diff against target: 521 lines (+181/-49)
6 files modified
turnip/pack/hookrpc.py (+11/-7)
turnip/pack/hooks/hook.py (+20/-13)
turnip/pack/tests/fake_servers.py (+4/-1)
turnip/pack/tests/test_functional.py (+48/-10)
turnip/pack/tests/test_hookrpc.py (+39/-12)
turnip/pack/tests/test_hooks.py (+59/-6)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Launchpad code reviewers Pending
Review via email: mp+359152@code.launchpad.net

Commit message

Use new bytes-capable checkRefPermissions protocol

Git ref paths may not be valid UTF-8, so we need to treat them as bytes.
We can't deal perfectly with non-UTF-8 refs - they won't get scanned and
so won't show up in the webservice API or the web UI - but we can at
least allow them to round-trip through Launchpad at the git level. This
migrates to a new version of the checkRefPermissions protocol which can
cope with ref paths being bytes rather than text.

LP: #1517559

Description of the change

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/turnip/pack/hookrpc.py b/turnip/pack/hookrpc.py
2index 7d818bf..b024734 100644
3--- a/turnip/pack/hookrpc.py
4+++ b/turnip/pack/hookrpc.py
5@@ -1,4 +1,4 @@
6-# Copyright 2015 Canonical Ltd. This software is licensed under the
7+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
8 # GNU Affero General Public License version 3 (see the file LICENSE).
9
10 """RPC server for Git hooks.
11@@ -20,8 +20,10 @@ from __future__ import (
12 unicode_literals,
13 )
14
15+import base64
16 import json
17
18+from six.moves import xmlrpc_client
19 from twisted.internet import (
20 defer,
21 protocol,
22@@ -124,22 +126,24 @@ class HookRPCHandler(object):
23 def checkRefPermissions(self, proto, args):
24 """Get permissions for a set of refs."""
25 cached_permissions = self.ref_permissions[args['key']]
26- missing = [x for x in args['paths']
27- if x not in cached_permissions]
28+ paths = [
29+ base64.b64decode(path.encode('UTF-8')) for path in args['paths']]
30+ missing = [x for x in paths if x not in cached_permissions]
31 if missing:
32 proxy = xmlrpc.Proxy(self.virtinfo_url, allowNone=True)
33 result = yield proxy.callRemote(
34 b'checkRefPermissions',
35 self.ref_paths[args['key']],
36- missing,
37+ [xmlrpc_client.Binary(path) for path in missing],
38 self.auth_params[args['key']]
39 )
40- for ref, permission in result.items():
41- cached_permissions[ref] = permission
42+ for ref, permission in result:
43+ cached_permissions[ref.data] = permission
44 # cached_permissions is a shallow copy of the key index for
45 # self.ref_permissions, so changes will be updated in that.
46 defer.returnValue(
47- {ref: cached_permissions[ref] for ref in args['paths']})
48+ {base64.b64encode(ref).decode('UTF-8'): cached_permissions[ref]
49+ for ref in paths})
50
51 @defer.inlineCallbacks
52 def notify(self, path):
53diff --git a/turnip/pack/hooks/hook.py b/turnip/pack/hooks/hook.py
54index 1594a4b..22251a2 100755
55--- a/turnip/pack/hooks/hook.py
56+++ b/turnip/pack/hooks/hook.py
57@@ -1,6 +1,6 @@
58 #!/usr/bin/python
59
60-# Copyright 2015 Canonical Ltd. This software is licensed under the
61+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
62 # GNU Affero General Public License version 3 (see the file LICENSE).
63
64 from __future__ import (
65@@ -9,6 +9,7 @@ from __future__ import (
66 unicode_literals,
67 )
68
69+import base64
70 import json
71 import os
72 import socket
73@@ -41,13 +42,13 @@ def determine_permissions_outcome(old, ref, rule_lines):
74 if 'create' in rule:
75 return
76 else:
77- return 'You do not have permission to create %s.' % ref
78+ return b'You do not have permission to create ' + ref + b'.'
79 # We have push permission, everything is okay
80 # force_push is checked later (in update-hook)
81 if 'push' in rule:
82 return
83 # If we're here, there are no matching rules
84- return "You do not have permission to push to %s." % ref
85+ return b"You do not have permission to push to " + ref + b"."
86
87
88 def match_rules(rule_lines, ref_lines):
89@@ -89,7 +90,7 @@ def match_update_rules(rule_lines, ref_line):
90 rule = rule_lines.get(ref, [])
91 if 'force_push' in rule:
92 return []
93- return ['You do not have permission to force-push to %s.' % ref]
94+ return [b'You do not have permission to force-push to ' + ref + b'.']
95
96
97 def netstring_send(sock, s):
98@@ -120,6 +121,16 @@ def rpc_invoke(sock, method, args):
99 return res['result']
100
101
102+def check_ref_permissions(sock, rpc_key, ref_paths):
103+ ref_paths = [base64.b64encode(path).decode('UTF-8') for path in ref_paths]
104+ rule_lines = rpc_invoke(
105+ sock, b'check_ref_permissions',
106+ {'key': rpc_key, 'paths': ref_paths})
107+ return {
108+ base64.b64decode(path.encode('UTF-8')): permissions
109+ for path, permissions in rule_lines.items()}
110+
111+
112 if __name__ == '__main__':
113 # Connect to the RPC server, authenticating using the random key
114 # from the environment.
115@@ -132,27 +143,23 @@ if __name__ == '__main__':
116 # Verify the proposed changes against rules from the server.
117 raw_paths = sys.stdin.readlines()
118 ref_paths = [p.rstrip(b'\n').split(b' ', 2)[2] for p in raw_paths]
119- rule_lines = rpc_invoke(
120- sock, b'check_ref_permissions',
121- {'key': rpc_key, 'paths': ref_paths})
122+ rule_lines = check_ref_permissions(sock, rpc_key, ref_paths)
123 errors = match_rules(rule_lines, raw_paths)
124 for error in errors:
125- sys.stdout.write(error + '\n')
126+ sys.stdout.write(error + b'\n')
127 sys.exit(1 if errors else 0)
128 elif hook == 'post-receive':
129 # Notify the server about the push if there were any changes.
130 # Details of the changes aren't currently included.
131 if sys.stdin.readlines():
132- rule_lines = rpc_invoke(sock, b'notify_push', {'key': rpc_key})
133+ rpc_invoke(sock, b'notify_push', {'key': rpc_key})
134 sys.exit(0)
135 elif hook == 'update':
136 ref = sys.argv[1]
137- rule_lines = rpc_invoke(
138- sock, b'check_ref_permissions',
139- {'key': rpc_key, 'paths': [ref]})
140+ rule_lines = check_ref_permissions(sock, rpc_key, [ref])
141 errors = match_update_rules(rule_lines, sys.argv[1:4])
142 for error in errors:
143- sys.stdout.write(error + '\n')
144+ sys.stdout.write(error + b'\n')
145 sys.exit(1 if errors else 0)
146 else:
147 sys.stderr.write('Invalid hook name: %s' % hook)
148diff --git a/turnip/pack/tests/fake_servers.py b/turnip/pack/tests/fake_servers.py
149index 1e0f310..e5951f0 100644
150--- a/turnip/pack/tests/fake_servers.py
151+++ b/turnip/pack/tests/fake_servers.py
152@@ -11,6 +11,7 @@ from collections import defaultdict
153 import hashlib
154
155 from lazr.sshserver.auth import NoSuchPersonWithName
156+from six.moves import xmlrpc_client
157 from twisted.web import xmlrpc
158
159 __all__ = [
160@@ -88,4 +89,6 @@ class FakeVirtInfoService(xmlrpc.XMLRPC):
161
162 def xmlrpc_checkRefPermissions(self, path, ref_paths, auth_params):
163 self.ref_permissions_checks.append((path, ref_paths, auth_params))
164- return self.ref_permissions
165+ return [
166+ (xmlrpc_client.Binary(ref), permissions)
167+ for ref, permissions in self.ref_permissions.items()]
168diff --git a/turnip/pack/tests/test_functional.py b/turnip/pack/tests/test_functional.py
169index 2fa1782..7d80cdf 100644
170--- a/turnip/pack/tests/test_functional.py
171+++ b/turnip/pack/tests/test_functional.py
172@@ -81,11 +81,13 @@ class FunctionalTestMixin(object):
173 self.virtinfo_url = b'http://localhost:%d/' % self.virtinfo_port
174 self.addCleanup(self.virtinfo_listener.stopListening)
175 self.virtinfo.ref_permissions = {
176- 'refs/heads/master': ['create', 'push']}
177+ b'refs/heads/master': ['create', 'push']}
178
179 def startHookRPC(self):
180 self.hookrpc_handler = HookRPCHandler(self.virtinfo_url)
181- dir = tempfile.mkdtemp(prefix='turnip-test-hook-')
182+ # XXX cjwatson 2018-11-20: Use bytes so that shutil.rmtree doesn't
183+ # get confused on Python 2.
184+ dir = tempfile.mkdtemp(prefix=b'turnip-test-hook-')
185 self.addCleanup(shutil.rmtree, dir, ignore_errors=True)
186
187 self.hookrpc_path = os.path.join(dir, 'hookrpc_sock')
188@@ -94,7 +96,9 @@ class FunctionalTestMixin(object):
189 self.addCleanup(self.hookrpc_listener.stopListening)
190
191 def startPackBackend(self):
192- self.root = tempfile.mkdtemp(prefix='turnip-test-root-')
193+ # XXX cjwatson 2018-11-20: Use bytes so that shutil.rmtree doesn't
194+ # get confused on Python 2.
195+ self.root = tempfile.mkdtemp(prefix=b'turnip-test-root-')
196 self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
197 self.backend_listener = reactor.listenTCP(
198 0,
199@@ -224,7 +228,7 @@ class FunctionalTestMixin(object):
200 @defer.inlineCallbacks
201 def test_no_permissions(self):
202 # Update the test ref_permissions
203- self.virtinfo.ref_permissions = {'refs/heads/master': ['push']}
204+ self.virtinfo.ref_permissions = {b'refs/heads/master': ['push']}
205 # Test a push fails if the user has no permissions to that ref
206 test_root = self.useFixture(TempDir()).path
207 clone1 = os.path.join(test_root, 'clone1')
208@@ -248,7 +252,7 @@ class FunctionalTestMixin(object):
209 error)
210
211 # add create, disable push
212- self.virtinfo.ref_permissions = {'refs/heads/master': ['create']}
213+ self.virtinfo.ref_permissions = {b'refs/heads/master': ['create']}
214 # Can now create the ref
215 yield self.assertCommandSuccess(
216 (b'git', b'push', b'origin', b'master'), path=clone1)
217@@ -263,10 +267,44 @@ class FunctionalTestMixin(object):
218 b"You do not have permission to push to refs/heads/master", error)
219
220 @defer.inlineCallbacks
221+ def test_push_non_ascii_refs(self):
222+ # Pushing non-ASCII refs works.
223+ self.virtinfo.ref_permissions = {
224+ b'refs/heads/\x80': ['create', 'push'],
225+ u'refs/heads/\N{SNOWMAN}'.encode('UTF-8'): ['create', 'push'],
226+ }
227+ test_root = self.useFixture(TempDir()).path
228+ clone1 = os.path.join(test_root, 'clone1')
229+ clone2 = os.path.join(test_root, 'clone2')
230+ yield self.assertCommandSuccess((b'git', b'clone', self.url, clone1))
231+ yield self.assertCommandSuccess(
232+ (b'git', b'config', b'user.name', b'Test User'), path=clone1)
233+ yield self.assertCommandSuccess(
234+ (b'git', b'config', b'user.email', b'test@example.com'),
235+ path=clone1)
236+ yield self.assertCommandSuccess(
237+ (b'git', b'commit', b'--allow-empty', b'-m', b'Non-ASCII test'),
238+ path=clone1)
239+ yield self.assertCommandSuccess(
240+ (b'git', b'push', b'origin', b'master:\x80',
241+ u'master:\N{SNOWMAN}'.encode('UTF-8')), path=clone1)
242+ # We get the new branches when we re-clone.
243+ yield self.assertCommandSuccess((b'git', b'clone', self.url, clone2))
244+ out = yield self.assertCommandSuccess(
245+ (b'git', b'for-each-ref', b'--format=%(refname)',
246+ b'refs/remotes/origin/*'),
247+ path=clone2)
248+ self.assertEqual(
249+ sorted([
250+ b'refs/remotes/origin/\x80',
251+ u'refs/remotes/origin/\N{SNOWMAN}'.encode('UTF-8')]),
252+ sorted(out.splitlines()))
253+
254+ @defer.inlineCallbacks
255 def test_force_push(self):
256 # Update the test ref_permissions
257 self.virtinfo.ref_permissions = {
258- 'refs/heads/master': ['create', 'push']}
259+ b'refs/heads/master': ['create', 'push']}
260
261 # Test a force-push fails if the user has no permissions
262 test_root = self.useFixture(TempDir()).path
263@@ -347,7 +385,7 @@ class FunctionalTestMixin(object):
264 @defer.inlineCallbacks
265 def test_delete_ref(self):
266 self.virtinfo.ref_permissions = {
267- 'refs/heads/newref': ['create', 'push', 'force_push']}
268+ b'refs/heads/newref': ['create', 'push', 'force_push']}
269 test_root = self.useFixture(TempDir()).path
270 clone1 = os.path.join(test_root, 'clone1')
271
272@@ -383,7 +421,7 @@ class FunctionalTestMixin(object):
273 @defer.inlineCallbacks
274 def test_delete_ref_without_permission(self):
275 self.virtinfo.ref_permissions = {
276- 'refs/heads/newref': ['create', 'push']}
277+ b'refs/heads/newref': ['create', 'push']}
278 test_root = self.useFixture(TempDir()).path
279 clone1 = os.path.join(test_root, 'clone1')
280
281@@ -471,7 +509,7 @@ class FrontendFunctionalTestMixin(FunctionalTestMixin):
282 b'localhost', self.backend_port, self.virtinfo_url))
283 self.virt_port = self.virt_listener.getHost().port
284 self.virtinfo.ref_permissions = {
285- 'refs/heads/master': ['create', 'push']}
286+ b'refs/heads/master': ['create', 'push']}
287
288 @defer.inlineCallbacks
289 def tearDown(self):
290@@ -482,7 +520,7 @@ class FrontendFunctionalTestMixin(FunctionalTestMixin):
291 @defer.inlineCallbacks
292 def test_read_only(self):
293 self.virtinfo.ref_permissions = {
294- 'refs/heads/master': ['create', 'push']}
295+ b'refs/heads/master': ['create', 'push']}
296 test_root = self.useFixture(TempDir()).path
297 clone1 = os.path.join(test_root, 'clone1')
298 clone2 = os.path.join(test_root, 'clone2')
299diff --git a/turnip/pack/tests/test_hookrpc.py b/turnip/pack/tests/test_hookrpc.py
300index 9af0408..5109ecd 100644
301--- a/turnip/pack/tests/test_hookrpc.py
302+++ b/turnip/pack/tests/test_hookrpc.py
303@@ -7,11 +7,20 @@ from __future__ import (
304 unicode_literals,
305 )
306
307+import base64
308 import contextlib
309 import uuid
310
311+from six.moves import xmlrpc_client
312 from testtools import TestCase
313 from testtools.deferredruntest import AsynchronousDeferredRunTest
314+from testtools.matchers import (
315+ Equals,
316+ IsInstance,
317+ MatchesAll,
318+ MatchesListwise,
319+ MatchesStructure,
320+ )
321 from twisted.internet import (
322 defer,
323 reactor,
324@@ -164,22 +173,39 @@ class TestHookRPCHandler(TestCase):
325 finally:
326 self.hookrpc_handler.unregisterKey(key)
327
328+ def encodeRefPath(self, ref_path):
329+ return base64.b64encode(ref_path).decode('UTF-8')
330+
331 def assertCheckedRefPermissions(self, path, ref_paths, auth_params):
332- self.assertEqual(
333- [(path, ref_paths, auth_params)],
334- self.virtinfo.ref_permissions_checks)
335+ self.assertThat(self.virtinfo.ref_permissions_checks, MatchesListwise([
336+ MatchesListwise([
337+ Equals(path),
338+ MatchesListwise([
339+ MatchesAll(
340+ IsInstance(xmlrpc_client.Binary),
341+ MatchesStructure.byEquality(data=ref_path))
342+ for ref_path in ref_paths
343+ ]),
344+ Equals(auth_params),
345+ ]),
346+ ]))
347
348 @defer.inlineCallbacks
349 def test_checkRefPermissions_fresh(self):
350 self.virtinfo.ref_permissions = {
351- 'refs/heads/master': ['push'],
352- 'refs/heads/next': ['force_push'],
353+ b'refs/heads/master': ['push'],
354+ b'refs/heads/next': ['force_push'],
355 }
356+ encoded_paths = [
357+ self.encodeRefPath(ref_path)
358+ for ref_path in sorted(self.virtinfo.ref_permissions)]
359 with self.registeredKey('/translated', auth_params={'uid': 42}) as key:
360 permissions = yield self.hookrpc_handler.checkRefPermissions(
361- None,
362- {'key': key, 'paths': sorted(self.virtinfo.ref_permissions)})
363- self.assertEqual(self.virtinfo.ref_permissions, permissions)
364+ None, {'key': key, 'paths': encoded_paths})
365+ expected_permissions = {
366+ self.encodeRefPath(ref_path): perms
367+ for ref_path, perms in self.virtinfo.ref_permissions.items()}
368+ self.assertEqual(expected_permissions, permissions)
369 self.assertCheckedRefPermissions(
370 '/translated', [b'refs/heads/master', b'refs/heads/next'],
371 {'uid': 42})
372@@ -187,15 +213,16 @@ class TestHookRPCHandler(TestCase):
373 @defer.inlineCallbacks
374 def test_checkRefPermissions_cached(self):
375 cached_ref_permissions = {
376- 'refs/heads/master': ['push'],
377- 'refs/heads/next': ['force_push'],
378+ b'refs/heads/master': ['push'],
379+ b'refs/heads/next': ['force_push'],
380 }
381+ encoded_master = self.encodeRefPath(b'refs/heads/master')
382 with self.registeredKey(
383 '/translated', auth_params={'uid': 42},
384 permissions=cached_ref_permissions) as key:
385 permissions = yield self.hookrpc_handler.checkRefPermissions(
386- None, {'key': key, 'paths': ['refs/heads/master']})
387- expected_permissions = {'refs/heads/master': ['push']}
388+ None, {'key': key, 'paths': [encoded_master]})
389+ expected_permissions = {encoded_master: ['push']}
390 self.assertEqual(expected_permissions, permissions)
391 self.assertEqual([], self.virtinfo.ref_permissions_checks)
392
393diff --git a/turnip/pack/tests/test_hooks.py b/turnip/pack/tests/test_hooks.py
394index ebf9c80..7aaf356 100644
395--- a/turnip/pack/tests/test_hooks.py
396+++ b/turnip/pack/tests/test_hooks.py
397@@ -1,4 +1,5 @@
398-# Copyright 2015 Canonical Ltd. This software is licensed under the
399+# -*- coding: utf-8 -*-
400+# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
401 # GNU Affero General Public License version 3 (see the file LICENSE).
402
403 from __future__ import (
404@@ -7,6 +8,7 @@ from __future__ import (
405 unicode_literals,
406 )
407
408+import base64
409 import os.path
410 import uuid
411
412@@ -61,7 +63,9 @@ class MockHookRPCHandler(hookrpc.HookRPCHandler):
413 self.notifications.append(self.ref_paths[args['key']])
414
415 def checkRefPermissions(self, proto, args):
416- return self.ref_permissions[args['key']]
417+ return {
418+ base64.b64encode(ref).decode('UTF-8'): permissions
419+ for ref, permissions in self.ref_permissions[args['key']].items()}
420
421
422 class MockRef(object):
423@@ -147,7 +151,15 @@ class TestPreReceiveHook(HookTestMixin, TestCase):
424 # A single valid ref is accepted.
425 yield self.assertAccepted(
426 [(b'refs/heads/master', self.old_sha1, self.new_sha1)],
427- {'refs/heads/master': ['push']})
428+ {b'refs/heads/master': ['push']})
429+
430+ @defer.inlineCallbacks
431+ def test_accepted_non_ascii(self):
432+ # Valid non-ASCII refs are accepted.
433+ paths = [b'refs/heads/\x80', u'refs/heads/géag'.encode('UTF-8')]
434+ yield self.assertAccepted(
435+ [(path, self.old_sha1, self.new_sha1) for path in paths],
436+ {path: ['push'] for path in paths})
437
438 @defer.inlineCallbacks
439 def test_rejected(self):
440@@ -171,6 +183,16 @@ class TestPreReceiveHook(HookTestMixin, TestCase):
441 b"You do not have permission to push "
442 b"to refs/heads/super-verboten.\n")
443
444+ @defer.inlineCallbacks
445+ def test_rejected_non_ascii(self):
446+ # Invalid non-ASCII refs are rejected.
447+ paths = [b'refs/heads/\x80', u'refs/heads/géag'.encode('UTF-8')]
448+ yield self.assertRejected(
449+ [(path, self.old_sha1, self.new_sha1) for path in paths],
450+ {path: [] for path in paths},
451+ b"You do not have permission to push to refs/heads/\x80.\n"
452+ b"You do not have permission to push to refs/heads/g\xc3\xa9ag.\n")
453+
454
455 class TestPostReceiveHook(HookTestMixin, TestCase):
456 """Tests for the git post-receive hook."""
457@@ -212,12 +234,12 @@ class TestUpdateHook(TestCase):
458 # Creation is determined by an all 0 base sha
459 self.assertEqual(
460 [], hook.match_update_rules(
461- [], ['ref', pygit2.GIT_OID_HEX_ZERO, 'new']))
462+ {}, [b'ref', pygit2.GIT_OID_HEX_ZERO, 'new']))
463
464 def test_fast_forward(self):
465 # If the old sha is a merge ancestor of the new
466 self.assertEqual(
467- [], hook.match_update_rules([], ['ref', 'somehex', 'new']))
468+ [], hook.match_update_rules({}, ['ref', 'somehex', 'new']))
469
470 def test_rules_fall_through(self):
471 # The default is to deny
472@@ -229,10 +251,27 @@ class TestUpdateHook(TestCase):
473 # No matches means deny by default
474 output = hook.match_update_rules(
475 {'notamatch': []},
476- ['ref', 'old', 'new'])
477+ [b'ref', 'old', 'new'])
478 self.assertEqual(
479 [b'You do not have permission to force-push to ref.'], output)
480
481+ def test_no_matching_non_utf8_ref(self):
482+ # An unmatched non-UTF-8 ref is denied.
483+ output = hook.match_update_rules(
484+ {}, [b'refs/heads/\x80', 'old', 'new'])
485+ self.assertEqual(
486+ [b'You do not have permission to force-push to refs/heads/\x80.'],
487+ output)
488+
489+ def test_no_matching_utf8_ref(self):
490+ # An unmatched UTF-8 ref is denied.
491+ output = hook.match_update_rules(
492+ {}, [u'refs/heads/géag'.encode('UTF-8'), 'old', 'new'])
493+ self.assertEqual(
494+ [b'You do not have permission to force-push to '
495+ b'refs/heads/g\xc3\xa9ag.'],
496+ output)
497+
498 def test_matching_ref(self):
499 # Permission given to force-push
500 output = hook.match_update_rules(
501@@ -240,6 +279,20 @@ class TestUpdateHook(TestCase):
502 ['ref', 'old', 'new'])
503 self.assertEqual([], output)
504
505+ def test_matching_non_utf8_ref(self):
506+ # A non-UTF-8 ref with force-push permission is accepted.
507+ output = hook.match_update_rules(
508+ {b'refs/heads/\x80': ['force_push']},
509+ [b'refs/heads/\x80', 'old', 'new'])
510+ self.assertEqual([], output)
511+
512+ def test_matching_utf8_ref(self):
513+ # A UTF-8 ref with force-push permission is accepted.
514+ output = hook.match_update_rules(
515+ {u'refs/heads/géag'.encode('UTF-8'): ['force_push']},
516+ [u'refs/heads/géag'.encode('UTF-8'), 'old', 'new'])
517+ self.assertEqual([], output)
518+
519 def test_no_permission(self):
520 # User does not have permission to force-push
521 output = hook.match_update_rules(

Subscribers

People subscribed via source and target branches