Merge lp:~spiv/bzr/bzr-ssh-homedir-take-3 into lp:bzr

Proposed by Andrew Bennetts
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
Reviewer Review Type Date Requested Status
Martin Pool Approve
Review via email: mp+11844@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Andrew Bennetts (spiv) wrote :

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 PathFilteringTransport 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.

Revision history for this message
Martin Pool (mbp) wrote :
Download full text (9.5 KiB)

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/smart/server.py'
--- bzrlib/smart/server.py 2009-07-20 11:27:05 +0000
+++ bzrlib/smart/server.py 2009-09-16 04:42:12 +0000
@@ -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(globals(), """
 from bzrlib.smart import medium
+from bzrlib.transport import (
+ chroot,
+ get_transport,
+ pathfilter,
+ )
+from bzrlib import (
+ urlutils,
+ )
 """)

@@ -313,34 +322,125 @@
         return transport.get_transport(url)

-def serve_bzr(transport, host=None, port=None, inet=False):
- from bzrlib import lockdir, ui
- from bzrlib.transport import get_transport
- from bzrlib.transport.chroot import ChrootServer
- chroot_server = ChrootServer(transport)
- chroot_server.setUp()
- transport = get_transport(chroot_server.get_url())
- if inet:
- smart_server = medium.SmartServerPipeStreamMedium(
- sys.stdin, sys.stdout, transport)
     else:
- if host is None:
- host = medium.BZR_DEFAULT_INTERFACE
- if port is None:
- port = medium.BZR_DEFAULT_PORT
- smart_server = SmartTCPServer(transport, host=host, port=port)
- trace.note('listening on port: %s' % smart_server.port)
- # 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._DEFAULT_TIMEOUT_SECONDS
- try:
+def _local_path_for_transport(transport):
+ """Return a local path for transport, if reasonably possible.
+
+ This function works even if transport's url has a "readonly+" prefix,
+ unlike local_path_from_url.
+
+ This essentially recovers the --directory argument the user passed to "bzr
+ serve" from the transport passed to serve_bzr.
+ """
+ try:
+ base_url = transport.external_url()
+ except (errors.InProcessTransport, NotImplementedError):
+ return None
     else:
+ # Strip readonly prefix
+ if base_url.startswith('readonly+'):
+ base_url = base_url[len('readonly+'):]
+ try:
+ return urlutils.local_path_from_url(base_url)
+ 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...

Read more...

Revision history for this message
Andrew Bennetts (spiv) wrote :
Download full text (3.4 KiB)

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_path_for_transport(transport):
[...]
> 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_server_registry that (at least) bzr-svn
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_server_registry, I think probably just something that can replace just
the _make_smart_server portion of BzrServerFactory would be more appropriate.

> +class BzrServerMaker(object):
>
> 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 BzrServerFactory(object):
    """Helper class for serve_bzr."""

and:

def serve_bzr(transport, host=None, port=None, inet=False):
    """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.path.expanduser).
    """

[...]
> + def _expand_userdirs(self, path):
> + """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...

Read more...

Revision history for this message
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://bob@host/~/repo``
  * ``bzr+ssh://bob@host/~bob/repo``
  * ``bzr+ssh://bob@host/home/bob/repo``

  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...

Revision history for this message
Martin Pool (mbp) wrote :
Download full text (4.2 KiB)

+class BzrServerFactory(object):
+ """Helper class for serve_bzr."""
+
+ def __init__(self, userdir_expander=None, get_base_path=None):
+ self.cleanups = []
+ self.base_path = None
+ self.backing_transport = None
+ if userdir_expander is None:
+ userdir_expander = os.path.expanduser
+ self.userdir_expander = userdir_expander
+ if get_base_path is None:
+ get_base_path = _local_path_for_transport
+ self.get_base_path = get_base_path
+
+ def _expand_userdirs(self, path):
+ """Translate /~/ or /~user/ to e.g. /home/foo, using
+ self.userdir_expander (os.path.expanduser by default).
+
+ 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_expander(path)
+ if not expanded.endswith('/'):
+ expanded += '/'
+ if expanded.startswith(self.base_path):
+ result = expanded[len(self.base_path):]
+ return result
+
+ def _make_expand_userdirs_filter(self, transport):
+ return pathfilter.PathFilteringServer(transport, self._expand_userdirs)
+
+ def _make_backing_transport(self, transport):
+ """Chroot transport, and decorate with userdir expander."""
+ self.base_path = self.get_base_path(transport)
+ chroot_server = chroot.ChrootServer(transport)
+ chroot_server.setUp()
+ self.cleanups.append(chroot_server.tearDown)
+ transport = get_transport(chroot_server.get_url())
+ 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_userdirs_filter(transport)
+ expand_userdirs.setUp()
+ self.cleanups.append(expand_userdirs.tearDown)
+ transport = get_transport(expand_userdirs.get_url())
+ 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_server(self, host, port, inet):
+ if inet:
+ smart_server = medium.SmartServerPipeStreamMedium(
+ sys.stdin, sys.stdout, self.transport)
+ else:
+ if host is None:
+ host = medium.BZR_DEFAULT_INTERFACE
+ if port is None:
+ port = medium.BZR_DEFAULT_PORT
+ smart_server = SmartTCPServer(self.transport, host=host, port=port)
+ trace.note('listening on port: %s' % smart_server.port)
+ self.smart_server = smart_server
+
+ def _change_globals(self):
+ from bzrlib import lockdir, ui
+ # For the duration of this server, no UI output is permitt...

Read more...

review: Approve
Revision history for this message
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.

Revision history for this message
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://launchpad.net/~mbp/>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)]