From 4fedf0edede459106ffcce1ab56cba4700f80880 Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Sat, 19 Feb 2022 11:17:54 -0500
Subject: [PATCH] add the ability to make directories, link entries, and
 download them

---
 src/_zkapauthorizer/tahoe.py            | 60 +++++++++++++++--
 src/_zkapauthorizer/tests/test_tahoe.py | 89 +++++++++++++++++++++++--
 2 files changed, 140 insertions(+), 9 deletions(-)

diff --git a/src/_zkapauthorizer/tahoe.py b/src/_zkapauthorizer/tahoe.py
index 60d696e..549de1f 100644
--- a/src/_zkapauthorizer/tahoe.py
+++ b/src/_zkapauthorizer/tahoe.py
@@ -4,7 +4,7 @@ A library for interacting with a Tahoe-LAFS node.
 
 from collections.abc import Awaitable
 from functools import wraps
-from typing import Callable, List
+from typing import Callable, Iterable, List, Optional
 
 import treq
 from attrs import define
@@ -59,6 +59,8 @@ class TahoeAPIError(Exception):
     :ivar body: The HTTP response body.
     """
 
+    method: str
+    url: DecodedURL
     status: int
     body: str
 
@@ -94,11 +96,15 @@ async def upload(
     content = (await treq.content(resp)).decode("utf-8")
     if resp.code in (200, 201):
         return content
-    raise TahoeAPIError(resp.code, content)
+    raise TahoeAPIError("put", uri, resp.code, content)
 
 
 async def download(
-    client: HTTPClient, outpath: FilePath, api_root: DecodedURL, cap: str
+    client: HTTPClient,
+    outpath: FilePath,
+    api_root: DecodedURL,
+    cap: str,
+    child_path: Optional[Iterable[str]] = None,
 ) -> Awaitable:  # Awaitable[None] but this requires Python 3.9
     """
     Download the object identified by the given capability to the given path.
@@ -120,11 +126,55 @@ async def download(
     """
     outtemp = outpath.temporarySibling()
 
-    resp = await client.get(api_root.child("uri", cap).to_text())
+    uri = api_root.child("uri").child(cap)
+    if child_path is not None:
+        for segment in child_path:
+            uri = uri.child(segment)
+
+    resp = await client.get(uri)
     if resp.code == 200:
         with outtemp.open("w") as f:
             await treq.collect(resp, f.write)
         outtemp.moveTo(outpath)
     else:
         content = (await treq.content(resp)).decode("utf-8")
-        raise TahoeAPIError(resp.code, content)
+        raise TahoeAPIError("get", uri, resp.code, content)
+
+
+async def make_directory(
+    client: HTTPClient,
+    api_root: DecodedURL,
+) -> Awaitable:  # Awaitable[str] but this requires Python 3.9
+    """
+    Create a new mutable directory and return the write capability string.
+    """
+    uri = api_root.child("uri").add("t", "mkdir")
+    resp = await client.post(uri)
+    content = (await treq.content(resp)).decode("utf-8")
+    if resp.code == 200:
+        return content
+    raise TahoeAPIError("post", uri, resp.code, content)
+
+
+async def link(
+    client: HTTPClient,
+    api_root: DecodedURL,
+    dir_cap: str,
+    entry_name: str,
+    entry_cap: str,
+) -> Awaitable:
+    """
+    Link an object into a directory.
+
+    :param dir_cap: The capability string of the directory in which to create
+        the link.
+
+    :param entry_cap: The capability string of the object to link in to the
+        directory.
+    """
+    uri = api_root.child("uri").child(dir_cap).child(entry_name).add("t", "uri")
+    resp = await client.put(uri, data=entry_cap.encode("ascii"))
+    content = (await treq.content(resp)).decode("utf-8")
+    if resp.code == 200:
+        return None
+    raise TahoeAPIError("put", uri, resp.code, content)
diff --git a/src/_zkapauthorizer/tests/test_tahoe.py b/src/_zkapauthorizer/tests/test_tahoe.py
index 0efdfd0..3564bc4 100644
--- a/src/_zkapauthorizer/tests/test_tahoe.py
+++ b/src/_zkapauthorizer/tests/test_tahoe.py
@@ -14,13 +14,13 @@ from fixtures import TempDir
 from hyperlink import DecodedURL
 from testresources import TestResourceManager, setUpResources, tearDownResources
 from testtools import TestCase
-from testtools.matchers import Equals, Is, raises
+from testtools.matchers import Equals, Is, Not, raises
 from testtools.twistedsupport import AsynchronousDeferredRunTest
-from twisted.internet.defer import ensureDeferred, inlineCallbacks
+from twisted.internet.defer import Deferred, ensureDeferred, inlineCallbacks
 from twisted.python.filepath import FilePath
 from yaml import safe_dump
 
-from ..tahoe import async_retry, download, upload
+from ..tahoe import async_retry, download, link, make_directory, upload
 from .fixtures import Treq
 
 # A plausible value for the ``retry`` parameter of ``wait_for_path``.
@@ -299,6 +299,9 @@ class TahoeClientManager(TestResourceManager):
         return client
 
 
+_client_manager = TahoeClientManager()
+
+
 class UploadDownloadTestCase(TestCase):
     """
     Tests for ``upload`` and ``download``.
@@ -308,7 +311,7 @@ class UploadDownloadTestCase(TestCase):
     run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=60.0)
 
     # Get a Tahoe-LAFS client node connected to a storage node.
-    resources = [("client", TahoeClientManager())]
+    resources = [("client", _client_manager)]
 
     def setUp(self):
         super().setUp()
@@ -339,6 +342,84 @@ class UploadDownloadTestCase(TestCase):
         )
 
 
+class DirectoryTests(TestCase):
+    """
+    Tests for directory-related functionality.
+    """
+
+    # Support test methods that return a Deferred.
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=60.0)
+
+    # Get a Tahoe-LAFS client node connected to a storage node.
+    resources = [("client", _client_manager)]
+
+    def setUp(self):
+        super().setUp()
+        setUpResources(self, self.resources, None)
+        self.addCleanup(lambda: tearDownResources(self, self.resources, None))
+        # AsynchronousDeferredRunTest sets reactor on us.
+        self.httpclient = self.useFixture(Treq(self.reactor, case=self)).client()
+
+    @inlineCallbacks
+    def test_make_directory(self):
+        """
+        ``make_directory`` returns a coroutine that completes with the capability
+        of a new, empty directory.
+        """
+        dir_cap = yield Deferred.fromCoroutine(
+            make_directory(self.httpclient, self.client.node_url)
+        )
+
+        # If we can download it, consider that success.
+        outpath = FilePath(self.useFixture(TempDir()).join("dir_contents"))
+        yield Deferred.fromCoroutine(
+            download(self.httpclient, outpath, self.client.node_url, dir_cap)
+        )
+        self.assertThat(outpath.getContent(), Not(Equals(b"")))
+
+    @inlineCallbacks
+    def test_link(self):
+        """
+        ``link`` adds an entry to a directory.
+        """
+        tmp = FilePath(self.useFixture(TempDir()).path)
+        inpath = tmp.child("source")
+        inpath.setContent(b"some content")
+
+        dir_cap = yield Deferred.fromCoroutine(
+            make_directory(self.httpclient, self.client.node_url)
+        )
+        entry_name = "foo"
+        entry_cap = yield Deferred.fromCoroutine(
+            upload(self.httpclient, inpath, self.client.node_url),
+        )
+        yield Deferred.fromCoroutine(
+            link(
+                self.httpclient,
+                self.client.node_url,
+                dir_cap,
+                entry_name,
+                entry_cap,
+            ),
+        )
+
+        outpath = tmp.child("destination")
+        yield Deferred.fromCoroutine(
+            download(
+                self.httpclient,
+                outpath,
+                self.client.node_url,
+                dir_cap,
+                child_path=[entry_name],
+            ),
+        )
+
+        self.assertThat(
+            outpath.getContent(),
+            Equals(inpath.getContent()),
+        )
+
+
 class AsyncRetryTests(TestCase):
     """
     Tests for ``async_retry``.
-- 
GitLab