1 | # -*- coding: utf-8 -*- |
---|
2 | # Tahoe-LAFS -- secure, distributed storage grid |
---|
3 | # |
---|
4 | # Copyright © 2020 The Tahoe-LAFS Software Foundation |
---|
5 | # |
---|
6 | # This file is part of Tahoe-LAFS. |
---|
7 | # |
---|
8 | # See the docs/about.rst file for licensing information. |
---|
9 | |
---|
10 | """ |
---|
11 | Test-helpers for clients that use the WebUI. |
---|
12 | """ |
---|
13 | |
---|
14 | from __future__ import annotations |
---|
15 | |
---|
16 | import hashlib |
---|
17 | from typing import Iterable |
---|
18 | |
---|
19 | import attr |
---|
20 | |
---|
21 | from hyperlink import DecodedURL |
---|
22 | |
---|
23 | from twisted.web.resource import ( |
---|
24 | Resource, |
---|
25 | ) |
---|
26 | from twisted.web.iweb import ( |
---|
27 | IBodyProducer, |
---|
28 | ) |
---|
29 | from twisted.web import ( |
---|
30 | http, |
---|
31 | ) |
---|
32 | |
---|
33 | from twisted.internet.defer import ( |
---|
34 | succeed, |
---|
35 | ) |
---|
36 | |
---|
37 | from treq.client import ( |
---|
38 | HTTPClient, |
---|
39 | FileBodyProducer, |
---|
40 | ) |
---|
41 | from treq.testing import ( |
---|
42 | RequestTraversalAgent, |
---|
43 | ) |
---|
44 | from zope.interface import implementer |
---|
45 | |
---|
46 | import allmydata.uri |
---|
47 | from allmydata.util import ( |
---|
48 | base32, |
---|
49 | ) |
---|
50 | from ..util.dictutil import BytesKeyDict |
---|
51 | |
---|
52 | |
---|
53 | __all__ = ( |
---|
54 | "create_fake_tahoe_root", |
---|
55 | "create_tahoe_treq_client", |
---|
56 | ) |
---|
57 | |
---|
58 | |
---|
59 | class _FakeTahoeRoot(Resource, object): |
---|
60 | """ |
---|
61 | An in-memory 'fake' of a Tahoe WebUI root. Currently it only |
---|
62 | implements (some of) the `/uri` resource. |
---|
63 | """ |
---|
64 | |
---|
65 | def __init__(self, uri=None): |
---|
66 | """ |
---|
67 | :param uri: a Resource to handle the `/uri` tree. |
---|
68 | """ |
---|
69 | Resource.__init__(self) # this is an old-style class :( |
---|
70 | self._uri = uri |
---|
71 | self.putChild(b"uri", self._uri) |
---|
72 | |
---|
73 | def add_data(self, kind, data): |
---|
74 | fresh, cap = self._uri.add_data(kind, data) |
---|
75 | return cap |
---|
76 | |
---|
77 | |
---|
78 | KNOWN_CAPABILITIES = [ |
---|
79 | getattr(allmydata.uri, t).BASE_STRING |
---|
80 | for t in dir(allmydata.uri) |
---|
81 | if hasattr(getattr(allmydata.uri, t), 'BASE_STRING') |
---|
82 | ] |
---|
83 | |
---|
84 | |
---|
85 | def capability_generator(kind): |
---|
86 | """ |
---|
87 | Deterministically generates a stream of valid capabilities of the |
---|
88 | given kind. The N, K and size values aren't related to anything |
---|
89 | real. |
---|
90 | |
---|
91 | :param bytes kind: the kind of capability, like `URI:CHK` |
---|
92 | |
---|
93 | :returns: a generator that yields new capablities of a particular |
---|
94 | kind. |
---|
95 | """ |
---|
96 | if not isinstance(kind, bytes): |
---|
97 | raise TypeError("'kind' must be bytes") |
---|
98 | |
---|
99 | if kind not in KNOWN_CAPABILITIES: |
---|
100 | raise ValueError( |
---|
101 | "Unknown capability kind '{}' (valid are {})".format( |
---|
102 | kind.decode('ascii'), |
---|
103 | ", ".join([x.decode('ascii') for x in KNOWN_CAPABILITIES]), |
---|
104 | ) |
---|
105 | ) |
---|
106 | # what we do here is to start with empty hashers for the key and |
---|
107 | # ueb_hash and repeatedly feed() them a zero byte on each |
---|
108 | # iteration .. so the same sequence of capabilities will always be |
---|
109 | # produced. We could add a seed= argument if we wanted to produce |
---|
110 | # different sequences. |
---|
111 | number = 0 |
---|
112 | key_hasher = hashlib.new("sha256") |
---|
113 | ueb_hasher = hashlib.new("sha256") # ueb means "URI Extension Block" |
---|
114 | |
---|
115 | # capabilities are "prefix:<128-bits-base32>:<256-bits-base32>:N:K:size" |
---|
116 | while True: |
---|
117 | number += 1 |
---|
118 | key_hasher.update(b"\x00") |
---|
119 | ueb_hasher.update(b"\x00") |
---|
120 | |
---|
121 | key = base32.b2a(key_hasher.digest()[:16]) # key is 16 bytes |
---|
122 | ueb_hash = base32.b2a(ueb_hasher.digest()) # ueb hash is 32 bytes |
---|
123 | |
---|
124 | cap = u"{kind}{key}:{ueb_hash}:{n}:{k}:{size}".format( |
---|
125 | kind=kind.decode('ascii'), |
---|
126 | key=key.decode('ascii'), |
---|
127 | ueb_hash=ueb_hash.decode('ascii'), |
---|
128 | n=1, |
---|
129 | k=1, |
---|
130 | size=number * 1000, |
---|
131 | ) |
---|
132 | yield cap.encode("ascii") |
---|
133 | |
---|
134 | |
---|
135 | @attr.s |
---|
136 | class _FakeTahoeUriHandler(Resource, object): |
---|
137 | """ |
---|
138 | An in-memory fake of (some of) the `/uri` endpoint of a Tahoe |
---|
139 | WebUI |
---|
140 | """ |
---|
141 | |
---|
142 | isLeaf = True |
---|
143 | |
---|
144 | data: BytesKeyDict = attr.ib(default=attr.Factory(BytesKeyDict)) |
---|
145 | capability_generators: dict[bytes,Iterable[bytes]] = attr.ib(default=attr.Factory(dict)) |
---|
146 | |
---|
147 | def _generate_capability(self, kind): |
---|
148 | """ |
---|
149 | :param str kind: any valid capability-string type |
---|
150 | |
---|
151 | :returns: the next capability-string for the given kind |
---|
152 | """ |
---|
153 | if kind not in self.capability_generators: |
---|
154 | self.capability_generators[kind] = capability_generator(kind) |
---|
155 | capability = next(self.capability_generators[kind]) |
---|
156 | return capability |
---|
157 | |
---|
158 | def add_data(self, kind, data): |
---|
159 | """ |
---|
160 | adds some data to our grid |
---|
161 | |
---|
162 | :returns: a two-tuple: a bool (True if the data is freshly added) and a capability-string |
---|
163 | """ |
---|
164 | if not isinstance(kind, bytes): |
---|
165 | raise TypeError("'kind' must be bytes") |
---|
166 | if not isinstance(data, bytes): |
---|
167 | raise TypeError("'data' must be bytes") |
---|
168 | |
---|
169 | for k in self.data: |
---|
170 | if self.data[k] == data: |
---|
171 | return (False, k) |
---|
172 | |
---|
173 | cap = self._generate_capability(kind) |
---|
174 | # it should be impossible for this to already be in our data, |
---|
175 | # but check anyway to be sure |
---|
176 | if cap in self.data: |
---|
177 | raise Exception("Internal error; key already exists somehow") |
---|
178 | self.data[cap] = data |
---|
179 | return (True, cap) |
---|
180 | |
---|
181 | def render_PUT(self, request): |
---|
182 | data = request.content.read() |
---|
183 | fresh, cap = self.add_data(b"URI:CHK:", data) |
---|
184 | if fresh: |
---|
185 | request.setResponseCode(http.CREATED) # real code does this for brand-new files |
---|
186 | else: |
---|
187 | request.setResponseCode(http.OK) # replaced/modified files |
---|
188 | return cap |
---|
189 | |
---|
190 | def render_POST(self, request): |
---|
191 | t = request.args[u"t"][0] |
---|
192 | data = request.content.read() |
---|
193 | |
---|
194 | type_to_kind = { |
---|
195 | "mkdir-immutable": b"URI:DIR2-CHK:" |
---|
196 | } |
---|
197 | kind = type_to_kind[t] |
---|
198 | fresh, cap = self.add_data(kind, data) |
---|
199 | return cap |
---|
200 | |
---|
201 | def render_GET(self, request): |
---|
202 | uri = DecodedURL.from_text(request.uri.decode('utf8')) |
---|
203 | capability = None |
---|
204 | for arg, value in uri.query: |
---|
205 | if arg == u"uri": |
---|
206 | capability = value.encode("utf-8") |
---|
207 | # it's legal to use the form "/uri/<capability>" |
---|
208 | if capability is None and request.postpath and request.postpath[0]: |
---|
209 | capability = request.postpath[0] |
---|
210 | |
---|
211 | # if we don't yet have a capability, that's an error |
---|
212 | if capability is None: |
---|
213 | request.setResponseCode(http.BAD_REQUEST) |
---|
214 | return b"GET /uri requires uri=" |
---|
215 | |
---|
216 | # the user gave us a capability; if our Grid doesn't have any |
---|
217 | # data for it, that's an error. |
---|
218 | if capability not in self.data: |
---|
219 | request.setResponseCode(http.GONE) |
---|
220 | return u"No data for '{}'".format(capability.decode('ascii')).encode("utf-8") |
---|
221 | |
---|
222 | return self.data[capability] |
---|
223 | |
---|
224 | |
---|
225 | def create_fake_tahoe_root(): |
---|
226 | """ |
---|
227 | If you wish to pre-populate data into the fake Tahoe grid, retain |
---|
228 | a reference to this root by creating it yourself and passing it to |
---|
229 | `create_tahoe_treq_client`. For example:: |
---|
230 | |
---|
231 | root = create_fake_tahoe_root() |
---|
232 | cap_string = root.add_data(...) |
---|
233 | client = create_tahoe_treq_client(root) |
---|
234 | |
---|
235 | :returns: an IResource instance that will handle certain Tahoe URI |
---|
236 | endpoints similar to a real Tahoe server. |
---|
237 | """ |
---|
238 | root = _FakeTahoeRoot( |
---|
239 | uri=_FakeTahoeUriHandler(), |
---|
240 | ) |
---|
241 | return root |
---|
242 | |
---|
243 | |
---|
244 | @implementer(IBodyProducer) |
---|
245 | class _SynchronousProducer(object): |
---|
246 | """ |
---|
247 | A partial implementation of an :obj:`IBodyProducer` which produces its |
---|
248 | entire payload immediately. There is no way to access to an instance of |
---|
249 | this object from :obj:`RequestTraversalAgent` or :obj:`StubTreq`, or even a |
---|
250 | :obj:`Resource: passed to :obj:`StubTreq`. |
---|
251 | |
---|
252 | This does not implement the :func:`IBodyProducer.stopProducing` method, |
---|
253 | because that is very difficult to trigger. (The request from |
---|
254 | `RequestTraversalAgent` would have to be canceled while it is still in the |
---|
255 | transmitting state), and the intent is to use `RequestTraversalAgent` to |
---|
256 | make synchronous requests. |
---|
257 | """ |
---|
258 | |
---|
259 | def __init__(self, body): |
---|
260 | """ |
---|
261 | Create a synchronous producer with some bytes. |
---|
262 | """ |
---|
263 | if isinstance(body, FileBodyProducer): |
---|
264 | body = body._inputFile.read() |
---|
265 | |
---|
266 | if not isinstance(body, bytes): |
---|
267 | raise ValueError( |
---|
268 | "'body' must be bytes not '{}'".format(type(body)) |
---|
269 | ) |
---|
270 | self.body = body |
---|
271 | self.length = len(body) |
---|
272 | |
---|
273 | def startProducing(self, consumer): |
---|
274 | """ |
---|
275 | Immediately produce all data. |
---|
276 | """ |
---|
277 | consumer.write(self.body) |
---|
278 | return succeed(None) |
---|
279 | |
---|
280 | def stopProducing(self): |
---|
281 | pass |
---|
282 | |
---|
283 | def pauseProducing(self): |
---|
284 | pass |
---|
285 | |
---|
286 | def resumeProducing(self): |
---|
287 | pass |
---|
288 | |
---|
289 | |
---|
290 | def create_tahoe_treq_client(root=None): |
---|
291 | """ |
---|
292 | :param root: an instance created via `create_fake_tahoe_root`. The |
---|
293 | caller might want a copy of this to call `.add_data` for example. |
---|
294 | |
---|
295 | :returns: an instance of treq.client.HTTPClient wired up to |
---|
296 | in-memory fakes of the Tahoe WebUI. Only a subset of the real |
---|
297 | WebUI is available. |
---|
298 | """ |
---|
299 | |
---|
300 | if root is None: |
---|
301 | root = create_fake_tahoe_root() |
---|
302 | |
---|
303 | client = HTTPClient( |
---|
304 | agent=RequestTraversalAgent(root), |
---|
305 | data_to_body_producer=_SynchronousProducer, |
---|
306 | ) |
---|
307 | return client |
---|