Merge lp:~spiv/bzr/bzr-ssh-homedir-take-3 into lp:bzr
- bzr-ssh-homedir-take-3
- Merge into bzr.dev
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~spiv/bzr/bzr-ssh-homedir-take-3 |
Merge into: | lp:bzr |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~spiv/bzr/bzr-ssh-homedir-take-3 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Martin Pool | Approve | ||
Review via email: mp+11844@code.launchpad.net |
Commit message
Description of the change
Andrew Bennetts (spiv) wrote : | # |
Martin Pool (mbp) wrote : | # |
Thanks, that's great to have this finished off, and it's nice that this
covers all those cases and apparently makes the code clearer to boot.
I'm stopping for today but I'll try to read the rest of it tomorrow.
This needs a NEWS entry and possibly a small update to the user manual
just to say that it's possible.
=== modified file 'bzrlib/
--- bzrlib/
+++ bzrlib/
@@ -17,6 +17,7 @@
"""Server for smart-server protocol."""
import errno
+import os.path
import socket
import sys
import threading
@@ -30,6 +31,14 @@
from bzrlib.lazy_import import lazy_import
lazy_import(
from bzrlib.smart import medium
+from bzrlib.transport import (
+ chroot,
+ get_transport,
+ pathfilter,
+ )
+from bzrlib import (
+ urlutils,
+ )
""")
@@ -313,34 +322,125 @@
return transport.
-def serve_bzr(
- from bzrlib import lockdir, ui
- from bzrlib.transport import get_transport
- from bzrlib.
- chroot_server = ChrootServer(
- chroot_
- transport = get_transport(
- if inet:
- smart_server = medium.
- sys.stdin, sys.stdout, transport)
else:
- if host is None:
- host = medium.
- if port is None:
- port = medium.
- smart_server = SmartTCPServer(
- trace.note(
- # For the duration of this server, no UI output is permitted. note
- # that this may cause problems with blackbox tests. This should be
- # changed with care though, as we dont want to use bandwidth sending
- # progress over stderr to smart server clients!
- old_factory = ui.ui_factory
- old_lockdir_timeout = lockdir.
- try:
+def _local_
+ """Return a local path for transport, if reasonably possible.
+
+ This function works even if transport's url has a "readonly+" prefix,
+ unlike local_path_
+
+ This essentially recovers the --directory argument the user passed to "bzr
+ serve" from the transport passed to serve_bzr.
+ """
+ try:
+ base_url = transport.
+ except (errors.
+ return None
else:
+ # Strip readonly prefix
+ if base_url.
+ base_url = base_url[
+ try:
+ return urlutils.
+ except errors.InvalidURL:
+ return None
(Not brilliant diff alignment there so I tweaked it for clarity.)
I wonder if that method should be a method on Transport, or at least a
function in transport.py? It seems likely to be reinvented elsewhere
otherwise, if it is not in fact already somewhere in our code. (For
example how does a WorkingTree go from a transport to a local dir?)
+
+
+class B...
Andrew Bennetts (spiv) wrote : | # |
Martin Pool wrote:
> Thanks, that's great to have this finished off, and it's nice that this
> covers all those cases and apparently makes the code clearer to boot.
>
> I'm stopping for today but I'll try to read the rest of it tomorrow.
>
> This needs a NEWS entry and possibly a small update to the user manual
> just to say that it's possible.
Good points. I've done so.
[...]
> +def _local_
[...]
> I wonder if that method should be a method on Transport, or at least a
> function in transport.py? It seems likely to be reinvented elsewhere
> otherwise, if it is not in fact already somewhere in our code. (For
> example how does a WorkingTree go from a transport to a local dir?)
Yes, it's currently reinvented (with less paranoia) in bzr-svn, I think. Many
places can use external_url or local_abspath, but the wrinkle here is the
readonly decorator.
I don't think the right fix is to provide an API to try unwrap arbitrarily
complex transport decorators back to a local path, the right fix is to just pass
the original directory specified by the user directly to this code. That will
involve an API break for the transport_
is using, but would be worthwhile I think. There are other things we can (and
probably should) improve if we break that API, e.g. bzr-svn almost certainly
should be using the logic in _change_globals, but currently isn't. I think the
current design delegates too much to the function in the
transport_
the _make_smart_server portion of BzrServerFactory would be more appropriate.
> +class BzrServerMaker(
>
> We have many things called Factory but no Makers (yet). Is this a
> Factory perhaps?
Fair enough. Changed.
> Perhaps it could have a docstring?
Added; actually I think most of docs belong to bzr_serve, so I've added a longer
one to it. They are now:
class BzrServerFactor
"""Helper class for serve_bzr."""
and:
def serve_bzr(
"""This is the default implementation of 'bzr serve'.
It creates a TCP or pipe smart server on 'transport, and runs it. The
transport will be decorated with a chroot and pathfilter (using
os.
"""
[...]
> + def _expand_
> + """Translate /~/ or /~user/ to e.g. /home/foo, using
> + os.path.expanduser.
>
> Actually not necessarily os.path.expanduser.
Thanks, corrected.
[...]
> This seems to have a little tension that it's now almost a generic
> path-rewrite facility that just happens to be used for homedirs. So the
> name seems almost overly specific. But perhaps a generic method is
> yagni so this is not bad for now.
Yes, my thoughts exactly :)
[...]
> + """
> + if path.split('/', 1)[0] == '~user':
> + return '/home/user' + path[len('~user'):]
> + return path
>
> (Comment) this does seem to be calling out for a class where we can do
> these as url manipulations, not string manipulations. Maybe something a
> bit more abstract is already possible using urlutils?
Surprisi...
Andrew Bennetts (spiv) wrote : | # |
I've updated the branch with all the review comments so far.
For convenience, here's the NEWS entry that it adds under New Features in 2.1:
"""
* ``bzr+ssh`` and ``bzr`` paths can now be relative to home directories
specified in the URL. Paths starting with a path segment of ``~`` are
relative to the home directory of the user running the server, and paths
starting with ``~user`` are relative to the home directory of the named
user. For example, for a user "bob" with a home directory of
``/home/bob``, these URLs are all equivalent:
* ``bzr+ssh:
* ``bzr+ssh:
* ``bzr+ssh:
If ``bzr serve`` was invoked with a ``--directory`` argument, then no
home directories outside that directory will be accessible via this
method.
This is a feature of ``bzr serve``, so pre-2.1 clients will
automatically benefit from this feature when ``bzr`` on the server is
upgraded. (Andrew Bennetts, #109143)
"""
It's long, but it does render to HTML pretty well.
In fact, it feels like a very long way to say "finally it Just Works the way you'd intuitively expect it to". I guess it's not so bad to make this improvement extra visible...
Martin Pool (mbp) wrote : | # |
+class BzrServerFactor
+ """Helper class for serve_bzr."""
+
+ def __init__(self, userdir_
+ self.cleanups = []
+ self.base_path = None
+ self.backing_
+ if userdir_expander is None:
+ userdir_expander = os.path.expanduser
+ self.userdir_
+ if get_base_path is None:
+ get_base_path = _local_
+ self.get_base_path = get_base_path
+
+ def _expand_
+ """Translate /~/ or /~user/ to e.g. /home/foo, using
+ self.userdir_
+
+ If the translated path would fall outside base_path, or the path does
+ not start with ~, then no translation is applied.
+
+ If the path is inside, it is adjusted to be relative to the base path.
+
+ e.g. if base_path is /home, and the expanded path is /home/joe, then
+ the translated path is joe.
+ """
+ result = path
+ if path.startswith
+ expanded = self.userdir_
+ if not expanded.
+ expanded += '/'
+ if expanded.
+ result = expanded[
+ return result
+
+ def _make_expand_
+ return pathfilter.
+
+ def _make_backing_
+ """Chroot transport, and decorate with userdir expander."""
+ self.base_path = self.get_
+ chroot_server = chroot.
+ chroot_
+ self.cleanups.
+ transport = get_transport(
+ if self.base_path is not None:
+ # Decorate the server's backing transport with a filter that can
+ # expand homedirs.
+ expand_userdirs = self._make_
+ expand_
+ self.cleanups.
+ transport = get_transport(
+ self.transport = transport
The handling of get_base_path still seems just a bit convoluted to me,
though it's probably not worth polishing any more. Maybe it can wait
unless/until someone wants other more complicated filtering.
+
+ def _make_smart_
+ if inet:
+ smart_server = medium.
+ sys.stdin, sys.stdout, self.transport)
+ else:
+ if host is None:
+ host = medium.
+ if port is None:
+ port = medium.
+ smart_server = SmartTCPServer(
+ trace.note(
+ self.smart_server = smart_server
+
+ def _change_
+ from bzrlib import lockdir, ui
+ # For the duration of this server, no UI output is permitt...
Andrew Bennetts (spiv) wrote : | # |
Martin Pool wrote:
[...]
>
> The handling of get_base_path still seems just a bit convoluted to me,
> though it's probably not worth polishing any more. Maybe it can wait
> unless/until someone wants other more complicated filtering.
Yeah, to me too. It's another symptom of cmd_serve not just passing this value
to bzr_serve directly, I think.
[...]
>
> Unless this is also going to be used as a TestCase (really?) why not
> make them setup() and tear_down()? Or maybe that is the consistent style for
> transport servers?
Good point. It is consistent with transport servers, but I don't know that that
counts for much compared to being consistent with everything else in the
(non-test) code base. I've changed them to set_up and tear_down. ("set_up"
rather than "setup", because "set up" is a verb phrase whereas "setup" is a
noun, I think... and it's consistent with "setUp").
Sending to PQM now.
-Andrew.
Martin Pool (mbp) wrote : | # |
2009/9/18 Andrew Bennetts <email address hidden>:
> Martin Pool wrote:
> [...]
>>
>> The handling of get_base_path still seems just a bit convoluted to me,
>> though it's probably not worth polishing any more. Â Maybe it can wait
>> unless/until someone wants other more complicated filtering.
>
> Yeah, to me too. Â It's another symptom of cmd_serve not just passing this value
> to bzr_serve directly, I think.
I think it is reasonable for that layer to work with transports. I
think the expansion mechanism should probably be given either URLs or
transports, and then if a particular filter wants to say "does this
may to a local path" it can do so.
--
Martin <http://
Preview Diff
1 | === modified file 'bzrlib/smart/server.py' |
2 | --- bzrlib/smart/server.py 2009-07-20 11:27:05 +0000 |
3 | +++ bzrlib/smart/server.py 2009-09-16 04:42:12 +0000 |
4 | @@ -17,6 +17,7 @@ |
5 | """Server for smart-server protocol.""" |
6 | |
7 | import errno |
8 | +import os.path |
9 | import socket |
10 | import sys |
11 | import threading |
12 | @@ -30,6 +31,14 @@ |
13 | from bzrlib.lazy_import import lazy_import |
14 | lazy_import(globals(), """ |
15 | from bzrlib.smart import medium |
16 | +from bzrlib.transport import ( |
17 | + chroot, |
18 | + get_transport, |
19 | + pathfilter, |
20 | + ) |
21 | +from bzrlib import ( |
22 | + urlutils, |
23 | + ) |
24 | """) |
25 | |
26 | |
27 | @@ -313,34 +322,125 @@ |
28 | return transport.get_transport(url) |
29 | |
30 | |
31 | -def serve_bzr(transport, host=None, port=None, inet=False): |
32 | - from bzrlib import lockdir, ui |
33 | - from bzrlib.transport import get_transport |
34 | - from bzrlib.transport.chroot import ChrootServer |
35 | - chroot_server = ChrootServer(transport) |
36 | - chroot_server.setUp() |
37 | - transport = get_transport(chroot_server.get_url()) |
38 | - if inet: |
39 | - smart_server = medium.SmartServerPipeStreamMedium( |
40 | - sys.stdin, sys.stdout, transport) |
41 | +def _local_path_for_transport(transport): |
42 | + """Return a local path for transport, if reasonably possible. |
43 | + |
44 | + This function works even if transport's url has a "readonly+" prefix, |
45 | + unlike local_path_from_url. |
46 | + |
47 | + This essentially recovers the --directory argument the user passed to "bzr |
48 | + serve" from the transport passed to serve_bzr. |
49 | + """ |
50 | + try: |
51 | + base_url = transport.external_url() |
52 | + except (errors.InProcessTransport, NotImplementedError): |
53 | + return None |
54 | else: |
55 | - if host is None: |
56 | - host = medium.BZR_DEFAULT_INTERFACE |
57 | - if port is None: |
58 | - port = medium.BZR_DEFAULT_PORT |
59 | - smart_server = SmartTCPServer(transport, host=host, port=port) |
60 | - trace.note('listening on port: %s' % smart_server.port) |
61 | - # For the duration of this server, no UI output is permitted. note |
62 | - # that this may cause problems with blackbox tests. This should be |
63 | - # changed with care though, as we dont want to use bandwidth sending |
64 | - # progress over stderr to smart server clients! |
65 | - old_factory = ui.ui_factory |
66 | - old_lockdir_timeout = lockdir._DEFAULT_TIMEOUT_SECONDS |
67 | - try: |
68 | + # Strip readonly prefix |
69 | + if base_url.startswith('readonly+'): |
70 | + base_url = base_url[len('readonly+'):] |
71 | + try: |
72 | + return urlutils.local_path_from_url(base_url) |
73 | + except errors.InvalidURL: |
74 | + return None |
75 | + |
76 | + |
77 | +class BzrServerMaker(object): |
78 | + |
79 | + def __init__(self, userdir_expander=None, get_base_path=None): |
80 | + self.cleanups = [] |
81 | + self.base_path = None |
82 | + self.backing_transport = None |
83 | + if userdir_expander is None: |
84 | + userdir_expander = os.path.expanduser |
85 | + self.userdir_expander = userdir_expander |
86 | + if get_base_path is None: |
87 | + get_base_path = _local_path_for_transport |
88 | + self.get_base_path = get_base_path |
89 | + |
90 | + def _expand_userdirs(self, path): |
91 | + """Translate /~/ or /~user/ to e.g. /home/foo, using |
92 | + os.path.expanduser. |
93 | + |
94 | + If the translated path would fall outside base_path, or the path does |
95 | + not start with ~, then no translation is applied. |
96 | + |
97 | + If the path is inside, it is adjusted to be relative to the base path. |
98 | + |
99 | + e.g. if base_path is /home, and the expanded path is /home/joe, then |
100 | + the translated path is joe. |
101 | + """ |
102 | + result = path |
103 | + if path.startswith('~'): |
104 | + expanded = self.userdir_expander(path) |
105 | + if not expanded.endswith('/'): |
106 | + expanded += '/' |
107 | + if expanded.startswith(self.base_path): |
108 | + result = expanded[len(self.base_path):] |
109 | + return result |
110 | + |
111 | + def _make_expand_userdirs_filter(self, transport): |
112 | + return pathfilter.PathFilteringServer(transport, self._expand_userdirs) |
113 | + |
114 | + def _make_backing_transport(self, transport): |
115 | + """Chroot transport, and decorate with userdir expander.""" |
116 | + self.base_path = self.get_base_path(transport) |
117 | + chroot_server = chroot.ChrootServer(transport) |
118 | + chroot_server.setUp() |
119 | + self.cleanups.append(chroot_server.tearDown) |
120 | + transport = get_transport(chroot_server.get_url()) |
121 | + if self.base_path is not None: |
122 | + # Decorate the server's backing transport with a filter that can |
123 | + # expand homedirs. |
124 | + expand_userdirs = self._make_expand_userdirs_filter(transport) |
125 | + expand_userdirs.setUp() |
126 | + self.cleanups.append(expand_userdirs.tearDown) |
127 | + transport = get_transport(expand_userdirs.get_url()) |
128 | + self.transport = transport |
129 | + |
130 | + def _make_smart_server(self, host, port, inet): |
131 | + if inet: |
132 | + smart_server = medium.SmartServerPipeStreamMedium( |
133 | + sys.stdin, sys.stdout, self.transport) |
134 | + else: |
135 | + if host is None: |
136 | + host = medium.BZR_DEFAULT_INTERFACE |
137 | + if port is None: |
138 | + port = medium.BZR_DEFAULT_PORT |
139 | + smart_server = SmartTCPServer(self.transport, host=host, port=port) |
140 | + trace.note('listening on port: %s' % smart_server.port) |
141 | + self.smart_server = smart_server |
142 | + |
143 | + def _change_globals(self): |
144 | + from bzrlib import lockdir, ui |
145 | + # For the duration of this server, no UI output is permitted. note |
146 | + # that this may cause problems with blackbox tests. This should be |
147 | + # changed with care though, as we dont want to use bandwidth sending |
148 | + # progress over stderr to smart server clients! |
149 | + old_factory = ui.ui_factory |
150 | + old_lockdir_timeout = lockdir._DEFAULT_TIMEOUT_SECONDS |
151 | + def restore_default_ui_factory_and_lockdir_timeout(): |
152 | + ui.ui_factory = old_factory |
153 | + lockdir._DEFAULT_TIMEOUT_SECONDS = old_lockdir_timeout |
154 | + self.cleanups.append(restore_default_ui_factory_and_lockdir_timeout) |
155 | ui.ui_factory = ui.SilentUIFactory() |
156 | lockdir._DEFAULT_TIMEOUT_SECONDS = 0 |
157 | - smart_server.serve() |
158 | + |
159 | + def setUp(self, transport, host, port, inet): |
160 | + self._make_backing_transport(transport) |
161 | + self._make_smart_server(host, port, inet) |
162 | + self._change_globals() |
163 | + |
164 | + def tearDown(self): |
165 | + for cleanup in reversed(self.cleanups): |
166 | + cleanup() |
167 | + |
168 | + |
169 | +def serve_bzr(transport, host=None, port=None, inet=False): |
170 | + bzr_server = BzrServerMaker() |
171 | + try: |
172 | + bzr_server.setUp(transport, host, port, inet) |
173 | + bzr_server.smart_server.serve() |
174 | finally: |
175 | - ui.ui_factory = old_factory |
176 | - lockdir._DEFAULT_TIMEOUT_SECONDS = old_lockdir_timeout |
177 | + bzr_server.tearDown() |
178 | |
179 | |
180 | === modified file 'bzrlib/tests/blackbox/test_serve.py' |
181 | --- bzrlib/tests/blackbox/test_serve.py 2009-07-20 11:27:05 +0000 |
182 | +++ bzrlib/tests/blackbox/test_serve.py 2009-09-16 04:42:12 +0000 |
183 | @@ -18,6 +18,7 @@ |
184 | """Tests of the bzr serve command.""" |
185 | |
186 | import os |
187 | +import os.path |
188 | import signal |
189 | import subprocess |
190 | import sys |
191 | @@ -25,17 +26,21 @@ |
192 | import threading |
193 | |
194 | from bzrlib import ( |
195 | + builtins, |
196 | errors, |
197 | osutils, |
198 | revision as _mod_revision, |
199 | - transport, |
200 | ) |
201 | from bzrlib.branch import Branch |
202 | from bzrlib.bzrdir import BzrDir |
203 | from bzrlib.errors import ParamikoNotPresent |
204 | from bzrlib.smart import client, medium |
205 | -from bzrlib.smart.server import SmartTCPServer |
206 | -from bzrlib.tests import TestCaseWithTransport, TestSkipped |
207 | +from bzrlib.smart.server import BzrServerMaker, SmartTCPServer |
208 | +from bzrlib.tests import ( |
209 | + TestCaseWithTransport, |
210 | + TestCaseWithMemoryTransport, |
211 | + TestSkipped, |
212 | + ) |
213 | from bzrlib.trace import mutter |
214 | from bzrlib.transport import get_transport, remote |
215 | |
216 | @@ -316,4 +321,61 @@ |
217 | client_medium.disconnect() |
218 | |
219 | |
220 | +class TestUserdirExpansion(TestCaseWithMemoryTransport): |
221 | + |
222 | + def fake_expanduser(self, path): |
223 | + """A simple, environment-independent, function for the duration of this |
224 | + test. |
225 | + |
226 | + Paths starting with a path segment of '~user' will expand to start with |
227 | + '/home/user/'. Every other path will be unchanged. |
228 | + """ |
229 | + if path.split('/', 1)[0] == '~user': |
230 | + return '/home/user' + path[len('~user'):] |
231 | + return path |
232 | + |
233 | + def make_test_server(self, base_path='/'): |
234 | + """Make and setUp a BzrServerMaker, backed by a memory transport, and |
235 | + creat '/home/user' in that transport. |
236 | + """ |
237 | + bzr_server = BzrServerMaker( |
238 | + self.fake_expanduser, lambda t: base_path) |
239 | + mem_transport = self.get_transport() |
240 | + mem_transport.mkdir_multi(['home', 'home/user']) |
241 | + bzr_server.setUp(mem_transport, None, None, inet=True) |
242 | + self.addCleanup(bzr_server.tearDown) |
243 | + return bzr_server |
244 | + |
245 | + def test_bzr_serve_expands_userdir(self): |
246 | + bzr_server = self.make_test_server() |
247 | + self.assertTrue(bzr_server.smart_server.backing_transport.has('~user')) |
248 | + |
249 | + def test_bzr_serve_does_not_expand_userdir_outside_base(self): |
250 | + bzr_server = self.make_test_server('/foo') |
251 | + self.assertFalse(bzr_server.smart_server.backing_transport.has('~user')) |
252 | + |
253 | + def test_get_base_path(self): |
254 | + """cmd_serve will turn the --directory option into a LocalTransport |
255 | + (optionally decorated with 'readonly+'). BzrServerMaker can determine |
256 | + the original --directory from that transport. |
257 | + """ |
258 | + # Define a fake 'protocol' to capture the transport that cmd_serve |
259 | + # passes to serve_bzr. |
260 | + def capture_transport(transport, host, port, inet): |
261 | + self.bzr_serve_transport = transport |
262 | + cmd = builtins.cmd_serve() |
263 | + # Read-only |
264 | + cmd.run(directory='/a/b/c', protocol=capture_transport) |
265 | + server_maker = BzrServerMaker() |
266 | + self.assertEqual( |
267 | + 'readonly+file:///a/b/c/', self.bzr_serve_transport.base) |
268 | + self.assertEqual( |
269 | + u'/a/b/c/', server_maker.get_base_path(self.bzr_serve_transport)) |
270 | + # Read-write |
271 | + cmd.run(directory='/a/b/c', protocol=capture_transport, |
272 | + allow_writes=True) |
273 | + server_maker = BzrServerMaker() |
274 | + self.assertEqual('file:///a/b/c/', self.bzr_serve_transport.base) |
275 | + self.assertEqual( |
276 | + u'/a/b/c/', server_maker.get_base_path(self.bzr_serve_transport)) |
277 | |
278 | |
279 | === modified file 'bzrlib/tests/test_transport.py' |
280 | --- bzrlib/tests/test_transport.py 2009-03-23 14:59:43 +0000 |
281 | +++ bzrlib/tests/test_transport.py 2009-09-14 07:22:43 +0000 |
282 | @@ -48,6 +48,7 @@ |
283 | from bzrlib.transport.memory import MemoryTransport |
284 | from bzrlib.transport.local import (LocalTransport, |
285 | EmulatedWin32LocalTransport) |
286 | +from bzrlib.transport.pathfilter import PathFilteringServer |
287 | |
288 | |
289 | # TODO: Should possibly split transport-specific tests into their own files. |
290 | @@ -445,6 +446,90 @@ |
291 | server.tearDown() |
292 | |
293 | |
294 | +class PathFilteringDecoratorTransportTest(TestCase): |
295 | + """Pathfilter decoration specific tests.""" |
296 | + |
297 | + def test_abspath(self): |
298 | + # The abspath is always relative to the base of the backing transport. |
299 | + server = PathFilteringServer(get_transport('memory:///foo/bar/'), |
300 | + lambda x: x) |
301 | + server.setUp() |
302 | + transport = get_transport(server.get_url()) |
303 | + self.assertEqual(server.get_url(), transport.abspath('/')) |
304 | + |
305 | + subdir_transport = transport.clone('subdir') |
306 | + self.assertEqual(server.get_url(), subdir_transport.abspath('/')) |
307 | + server.tearDown() |
308 | + |
309 | + def make_pf_transport(self, filter_func=None): |
310 | + """Make a PathFilteringTransport backed by a MemoryTransport. |
311 | + |
312 | + :param filter_func: by default this will be a no-op function. Use this |
313 | + parameter to override it.""" |
314 | + if filter_func is None: |
315 | + filter_func = lambda x: x |
316 | + server = PathFilteringServer( |
317 | + get_transport('memory:///foo/bar/'), filter_func) |
318 | + server.setUp() |
319 | + self.addCleanup(server.tearDown) |
320 | + return get_transport(server.get_url()) |
321 | + |
322 | + def test__filter(self): |
323 | + # _filter (with an identity func as filter_func) always returns |
324 | + # paths relative to the base of the backing transport. |
325 | + transport = self.make_pf_transport() |
326 | + self.assertEqual('foo', transport._filter('foo')) |
327 | + self.assertEqual('foo/bar', transport._filter('foo/bar')) |
328 | + self.assertEqual('', transport._filter('..')) |
329 | + self.assertEqual('', transport._filter('/')) |
330 | + # The base of the pathfiltering transport is taken into account too. |
331 | + transport = transport.clone('subdir1/subdir2') |
332 | + self.assertEqual('subdir1/subdir2/foo', transport._filter('foo')) |
333 | + self.assertEqual( |
334 | + 'subdir1/subdir2/foo/bar', transport._filter('foo/bar')) |
335 | + self.assertEqual('subdir1', transport._filter('..')) |
336 | + self.assertEqual('', transport._filter('/')) |
337 | + |
338 | + def test_filter_invocation(self): |
339 | + filter_log = [] |
340 | + def filter(path): |
341 | + filter_log.append(path) |
342 | + return path |
343 | + transport = self.make_pf_transport(filter) |
344 | + transport.has('abc') |
345 | + self.assertEqual(['abc'], filter_log) |
346 | + del filter_log[:] |
347 | + transport.clone('abc').has('xyz') |
348 | + self.assertEqual(['abc/xyz'], filter_log) |
349 | + del filter_log[:] |
350 | + transport.has('/abc') |
351 | + self.assertEqual(['abc'], filter_log) |
352 | + |
353 | + def test_clone(self): |
354 | + transport = self.make_pf_transport() |
355 | + # relpath from root and root path are the same |
356 | + relpath_cloned = transport.clone('foo') |
357 | + abspath_cloned = transport.clone('/foo') |
358 | + self.assertEqual(transport.server, relpath_cloned.server) |
359 | + self.assertEqual(transport.server, abspath_cloned.server) |
360 | + |
361 | + def test_url_preserves_pathfiltering(self): |
362 | + """Calling get_transport on a pathfiltered transport's base should |
363 | + produce a transport with exactly the same behaviour as the original |
364 | + pathfiltered transport. |
365 | + |
366 | + This is so that it is not possible to escape (accidentally or |
367 | + otherwise) the filtering by doing:: |
368 | + url = filtered_transport.base |
369 | + parent_url = urlutils.join(url, '..') |
370 | + new_transport = get_transport(parent_url) |
371 | + """ |
372 | + transport = self.make_pf_transport() |
373 | + new_transport = get_transport(transport.base) |
374 | + self.assertEqual(transport.server, new_transport.server) |
375 | + self.assertEqual(transport.base, new_transport.base) |
376 | + |
377 | + |
378 | class ReadonlyDecoratorTransportTest(TestCase): |
379 | """Readonly decoration specific tests.""" |
380 | |
381 | |
382 | === modified file 'bzrlib/transport/__init__.py' |
383 | --- bzrlib/transport/__init__.py 2009-08-04 11:40:59 +0000 |
384 | +++ bzrlib/transport/__init__.py 2009-09-14 05:39:09 +0000 |
385 | @@ -90,8 +90,10 @@ |
386 | modules.add(factory._module_name) |
387 | else: |
388 | modules.add(factory._obj.__module__) |
389 | - # Add chroot directly, because there is no handler registered for it. |
390 | + # Add chroot and pathfilter directly, because there is no handler |
391 | + # registered for it. |
392 | modules.add('bzrlib.transport.chroot') |
393 | + modules.add('bzrlib.transport.pathfilter') |
394 | result = list(modules) |
395 | result.sort() |
396 | return result |
397 | |
398 | === modified file 'bzrlib/transport/chroot.py' |
399 | --- bzrlib/transport/chroot.py 2009-03-23 14:59:43 +0000 |
400 | +++ bzrlib/transport/chroot.py 2009-09-16 04:37:33 +0000 |
401 | @@ -1,4 +1,4 @@ |
402 | -# Copyright (C) 2006 Canonical Ltd |
403 | +# Copyright (C) 2006-2009 Canonical Ltd |
404 | # |
405 | # This program is free software; you can redistribute it and/or modify |
406 | # it under the terms of the GNU General Public License as published by |
407 | @@ -17,21 +17,18 @@ |
408 | """Implementation of Transport that prevents access to locations above a set |
409 | root. |
410 | """ |
411 | -from urlparse import urlparse |
412 | |
413 | -from bzrlib import errors, urlutils |
414 | from bzrlib.transport import ( |
415 | get_transport, |
416 | + pathfilter, |
417 | register_transport, |
418 | Server, |
419 | Transport, |
420 | unregister_transport, |
421 | ) |
422 | -from bzrlib.transport.decorator import TransportDecorator, DecoratorServer |
423 | -from bzrlib.transport.memory import MemoryTransport |
424 | - |
425 | - |
426 | -class ChrootServer(Server): |
427 | + |
428 | + |
429 | +class ChrootServer(pathfilter.PathFilteringServer): |
430 | """User space 'chroot' facility. |
431 | |
432 | The server's get_url returns the url for a chroot transport mapped to the |
433 | @@ -39,126 +36,40 @@ |
434 | directories of the backing transport are not visible. The chroot url will |
435 | not allow '..' sequences to result in requests to the chroot affecting |
436 | directories outside the backing transport. |
437 | + |
438 | + PathFilteringServer does all the path sanitation needed to enforce a |
439 | + chroot, so this is a simple subclass of PathFilteringServer that ignores |
440 | + filter_func. |
441 | """ |
442 | |
443 | def __init__(self, backing_transport): |
444 | - self.backing_transport = backing_transport |
445 | + pathfilter.PathFilteringServer.__init__(self, backing_transport, None) |
446 | |
447 | def _factory(self, url): |
448 | return ChrootTransport(self, url) |
449 | |
450 | - def get_url(self): |
451 | - return self.scheme |
452 | - |
453 | def setUp(self): |
454 | self.scheme = 'chroot-%d:///' % id(self) |
455 | register_transport(self.scheme, self._factory) |
456 | |
457 | - def tearDown(self): |
458 | - unregister_transport(self.scheme, self._factory) |
459 | - |
460 | - |
461 | -class ChrootTransport(Transport): |
462 | + |
463 | +class ChrootTransport(pathfilter.PathFilteringTransport): |
464 | """A ChrootTransport. |
465 | |
466 | Please see ChrootServer for details. |
467 | """ |
468 | |
469 | - def __init__(self, server, base): |
470 | - self.server = server |
471 | - if not base.endswith('/'): |
472 | - base += '/' |
473 | - Transport.__init__(self, base) |
474 | - self.base_path = self.base[len(self.server.scheme)-1:] |
475 | - self.scheme = self.server.scheme |
476 | - |
477 | - def _call(self, methodname, relpath, *args): |
478 | - method = getattr(self.server.backing_transport, methodname) |
479 | - return method(self._safe_relpath(relpath), *args) |
480 | - |
481 | - def _safe_relpath(self, relpath): |
482 | - safe_relpath = self._combine_paths(self.base_path, relpath) |
483 | - if not safe_relpath.startswith('/'): |
484 | - raise ValueError(safe_relpath) |
485 | - return safe_relpath[1:] |
486 | - |
487 | - # Transport methods |
488 | - def abspath(self, relpath): |
489 | - return self.scheme + self._safe_relpath(relpath) |
490 | - |
491 | - def append_file(self, relpath, f, mode=None): |
492 | - return self._call('append_file', relpath, f, mode) |
493 | - |
494 | - def _can_roundtrip_unix_modebits(self): |
495 | - return self.server.backing_transport._can_roundtrip_unix_modebits() |
496 | - |
497 | - def clone(self, relpath): |
498 | - return ChrootTransport(self.server, self.abspath(relpath)) |
499 | - |
500 | - def delete(self, relpath): |
501 | - return self._call('delete', relpath) |
502 | - |
503 | - def delete_tree(self, relpath): |
504 | - return self._call('delete_tree', relpath) |
505 | - |
506 | - def external_url(self): |
507 | - """See bzrlib.transport.Transport.external_url.""" |
508 | - # Chroots, like MemoryTransport depend on in-process |
509 | - # state and thus the base cannot simply be handed out. |
510 | - # See the base class docstring for more details and |
511 | - # possible directions. For now we return the chrooted |
512 | - # url. |
513 | - return self.server.backing_transport.external_url() |
514 | - |
515 | - def get(self, relpath): |
516 | - return self._call('get', relpath) |
517 | - |
518 | - def has(self, relpath): |
519 | - return self._call('has', relpath) |
520 | - |
521 | - def is_readonly(self): |
522 | - return self.server.backing_transport.is_readonly() |
523 | - |
524 | - def iter_files_recursive(self): |
525 | - backing_transport = self.server.backing_transport.clone( |
526 | - self._safe_relpath('.')) |
527 | - return backing_transport.iter_files_recursive() |
528 | - |
529 | - def listable(self): |
530 | - return self.server.backing_transport.listable() |
531 | - |
532 | - def list_dir(self, relpath): |
533 | - return self._call('list_dir', relpath) |
534 | - |
535 | - def lock_read(self, relpath): |
536 | - return self._call('lock_read', relpath) |
537 | - |
538 | - def lock_write(self, relpath): |
539 | - return self._call('lock_write', relpath) |
540 | - |
541 | - def mkdir(self, relpath, mode=None): |
542 | - return self._call('mkdir', relpath, mode) |
543 | - |
544 | - def open_write_stream(self, relpath, mode=None): |
545 | - return self._call('open_write_stream', relpath, mode) |
546 | - |
547 | - def put_file(self, relpath, f, mode=None): |
548 | - return self._call('put_file', relpath, f, mode) |
549 | - |
550 | - def rename(self, rel_from, rel_to): |
551 | - return self._call('rename', rel_from, self._safe_relpath(rel_to)) |
552 | - |
553 | - def rmdir(self, relpath): |
554 | - return self._call('rmdir', relpath) |
555 | - |
556 | - def stat(self, relpath): |
557 | - return self._call('stat', relpath) |
558 | + def _filter(self, relpath): |
559 | + # A simplified version of PathFilteringTransport's _filter that omits |
560 | + # the call to self.server.filter_func. |
561 | + return self._relpath_from_server_root(relpath) |
562 | |
563 | |
564 | class TestingChrootServer(ChrootServer): |
565 | |
566 | def __init__(self): |
567 | """TestingChrootServer is not usable until setUp is called.""" |
568 | + ChrootServer.__init__(self, None) |
569 | |
570 | def setUp(self, backing_server=None): |
571 | """Setup the Chroot on backing_server.""" |
572 | @@ -171,5 +82,4 @@ |
573 | |
574 | def get_test_permutations(): |
575 | """Return the permutations to be used in testing.""" |
576 | - return [(ChrootTransport, TestingChrootServer), |
577 | - ] |
578 | + return [(ChrootTransport, TestingChrootServer)] |
579 | |
580 | === added file 'bzrlib/transport/pathfilter.py' |
581 | --- bzrlib/transport/pathfilter.py 1970-01-01 00:00:00 +0000 |
582 | +++ bzrlib/transport/pathfilter.py 2009-09-16 04:37:33 +0000 |
583 | @@ -0,0 +1,195 @@ |
584 | +# Copyright (C) 2006 Canonical Ltd |
585 | +# |
586 | +# This program is free software; you can redistribute it and/or modify |
587 | +# it under the terms of the GNU General Public License as published by |
588 | +# the Free Software Foundation; either version 2 of the License, or |
589 | +# (at your option) any later version. |
590 | +# |
591 | +# This program is distributed in the hope that it will be useful, |
592 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
593 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
594 | +# GNU General Public License for more details. |
595 | +# |
596 | +# You should have received a copy of the GNU General Public License |
597 | +# along with this program; if not, write to the Free Software |
598 | +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
599 | + |
600 | +"""A transport decorator that filters all paths that are passed to it.""" |
601 | + |
602 | + |
603 | +from bzrlib.transport import ( |
604 | + get_transport, |
605 | + register_transport, |
606 | + Server, |
607 | + Transport, |
608 | + unregister_transport, |
609 | + ) |
610 | + |
611 | + |
612 | +class PathFilteringServer(Server): |
613 | + """Transport server for PathFilteringTransport. |
614 | + |
615 | + It holds the backing_transport and filter_func for PathFilteringTransports. |
616 | + All paths will be passed through filter_func before calling into the |
617 | + backing_transport. |
618 | + |
619 | + Note that paths returned from the backing transport are *not* altered in |
620 | + anyway. So, depending on the filter_func, PathFilteringTransports might |
621 | + not conform to the usual expectations of Transport behaviour; e.g. 'name' |
622 | + in t.list_dir('dir') might not imply t.has('dir/name') is True! A filter |
623 | + that merely prefixes a constant path segment will be essentially |
624 | + transparent, whereas a filter that does rot13 to paths will break |
625 | + expectations and probably cause confusing errors. So choose your |
626 | + filter_func with care. |
627 | + """ |
628 | + |
629 | + def __init__(self, backing_transport, filter_func): |
630 | + """Constructor. |
631 | + |
632 | + :param backing_transport: a transport |
633 | + :param filter_func: a callable that takes paths, and translates them |
634 | + into paths for use with the backing transport. |
635 | + """ |
636 | + self.backing_transport = backing_transport |
637 | + self.filter_func = filter_func |
638 | + |
639 | + def _factory(self, url): |
640 | + return PathFilteringTransport(self, url) |
641 | + |
642 | + def get_url(self): |
643 | + return self.scheme |
644 | + |
645 | + def setUp(self): |
646 | + self.scheme = 'filtered-%d:///' % id(self) |
647 | + register_transport(self.scheme, self._factory) |
648 | + |
649 | + def tearDown(self): |
650 | + unregister_transport(self.scheme, self._factory) |
651 | + |
652 | + |
653 | +class PathFilteringTransport(Transport): |
654 | + """A PathFilteringTransport. |
655 | + |
656 | + Please see PathFilteringServer for details. |
657 | + """ |
658 | + |
659 | + def __init__(self, server, base): |
660 | + self.server = server |
661 | + if not base.endswith('/'): |
662 | + base += '/' |
663 | + Transport.__init__(self, base) |
664 | + self.base_path = self.base[len(self.server.scheme)-1:] |
665 | + self.scheme = self.server.scheme |
666 | + |
667 | + def _relpath_from_server_root(self, relpath): |
668 | + unfiltered_path = self._combine_paths(self.base_path, relpath) |
669 | + if not unfiltered_path.startswith('/'): |
670 | + raise ValueError(unfiltered_path) |
671 | + return unfiltered_path[1:] |
672 | + |
673 | + def _filter(self, relpath): |
674 | + return self.server.filter_func(self._relpath_from_server_root(relpath)) |
675 | + |
676 | + def _call(self, methodname, relpath, *args): |
677 | + """Helper for Transport methods of the form: |
678 | + operation(path, [other args ...]) |
679 | + """ |
680 | + backing_method = getattr(self.server.backing_transport, methodname) |
681 | + return backing_method(self._filter(relpath), *args) |
682 | + |
683 | + # Transport methods |
684 | + def abspath(self, relpath): |
685 | + # We do *not* want to filter at this point; e.g if the filter is |
686 | + # homedir expansion, self.base == 'this:///' and relpath == '~/foo', |
687 | + # then the abspath should be this:///~/foo (not this:///home/user/foo). |
688 | + # Instead filtering should happen when self's base is passed to the |
689 | + # backing. |
690 | + return self.scheme + self._relpath_from_server_root(relpath) |
691 | + |
692 | + def append_file(self, relpath, f, mode=None): |
693 | + return self._call('append_file', relpath, f, mode) |
694 | + |
695 | + def _can_roundtrip_unix_modebits(self): |
696 | + return self.server.backing_transport._can_roundtrip_unix_modebits() |
697 | + |
698 | + def clone(self, relpath): |
699 | + return self.__class__(self.server, self.abspath(relpath)) |
700 | + |
701 | + def delete(self, relpath): |
702 | + return self._call('delete', relpath) |
703 | + |
704 | + def delete_tree(self, relpath): |
705 | + return self._call('delete_tree', relpath) |
706 | + |
707 | + def external_url(self): |
708 | + """See bzrlib.transport.Transport.external_url.""" |
709 | + # PathFilteringTransports, like MemoryTransport, depend on in-process |
710 | + # state and thus the base cannot simply be handed out. See the base |
711 | + # class docstring for more details and possible directions. For now we |
712 | + # return the path-filtered URL. |
713 | + return self.server.backing_transport.external_url() |
714 | + |
715 | + def get(self, relpath): |
716 | + return self._call('get', relpath) |
717 | + |
718 | + def has(self, relpath): |
719 | + return self._call('has', relpath) |
720 | + |
721 | + def is_readonly(self): |
722 | + return self.server.backing_transport.is_readonly() |
723 | + |
724 | + def iter_files_recursive(self): |
725 | + backing_transport = self.server.backing_transport.clone( |
726 | + self._filter('.')) |
727 | + return backing_transport.iter_files_recursive() |
728 | + |
729 | + def listable(self): |
730 | + return self.server.backing_transport.listable() |
731 | + |
732 | + def list_dir(self, relpath): |
733 | + return self._call('list_dir', relpath) |
734 | + |
735 | + def lock_read(self, relpath): |
736 | + return self._call('lock_read', relpath) |
737 | + |
738 | + def lock_write(self, relpath): |
739 | + return self._call('lock_write', relpath) |
740 | + |
741 | + def mkdir(self, relpath, mode=None): |
742 | + return self._call('mkdir', relpath, mode) |
743 | + |
744 | + def open_write_stream(self, relpath, mode=None): |
745 | + return self._call('open_write_stream', relpath, mode) |
746 | + |
747 | + def put_file(self, relpath, f, mode=None): |
748 | + return self._call('put_file', relpath, f, mode) |
749 | + |
750 | + def rename(self, rel_from, rel_to): |
751 | + return self._call('rename', rel_from, self._filter(rel_to)) |
752 | + |
753 | + def rmdir(self, relpath): |
754 | + return self._call('rmdir', relpath) |
755 | + |
756 | + def stat(self, relpath): |
757 | + return self._call('stat', relpath) |
758 | + |
759 | + |
760 | +class TestingPathFilteringServer(PathFilteringServer): |
761 | + |
762 | + def __init__(self): |
763 | + """TestingChrootServer is not usable until setUp is called.""" |
764 | + |
765 | + def setUp(self, backing_server=None): |
766 | + """Setup the Chroot on backing_server.""" |
767 | + if backing_server is not None: |
768 | + self.backing_transport = get_transport(backing_server.get_url()) |
769 | + else: |
770 | + self.backing_transport = get_transport('.') |
771 | + self.backing_transport.clone('added-by-filter').ensure_base() |
772 | + self.filter_func = lambda x: 'added-by-filter/' + x |
773 | + PathFilteringServer.setUp(self) |
774 | + |
775 | + |
776 | +def get_test_permutations(): |
777 | + """Return the permutations to be used in testing.""" |
778 | + return [(PathFilteringTransport, TestingPathFilteringServer)] |
This fixes bug 109143. It implements expansion of /~/... and /~user/... paths in the smart server. With this patch, the expansion will Just Work.
The core of the implementation is to use a PathFilteringTr ansport decorator (see <lp:~spiv/bzr/path-filtering-chroot>) to invoke os.path.expanduser.
This patch takes care to make sure this interacts sanely with edge cases like "bzr serve --directory=/home" ("~andrew" should not cause access to /home/home/andrew) and "bzr serve --directory= /nothome" (a client using ~andrew should not break out of the chroot that doesn't include /home/andrew).
I did a large refactoring of bzr_serve to break the work in to multiple functions, and then made those functions methods on a class to share the relevant state between them. This made it possible to adequately unit test the key parts of the new code. We can probably do better here, but I think that would involve breaking the public interface of transport_ server_ registry. e.g. it would be nice to avoid contortions to extract the original --directory from the command-line from the transport; ideally cmd_serve would have passed that to server factory more directly. Similarly, bzr-svn probably should be able to reuse the logic to change the default lock timeout and ui... This change is a good step in the right direction though, and doesn't break any APIs used by e.g. bzr-svn.
I've added some unit tests to blackbox. test_serve, which feels a little bit weird. But they are unit tests for the "bzr serve" UI, so this seems like the best home we have for that sort of thing.