Skip to content

Commit 67fb57d

Browse files
committed
Tests
1 parent 3427b61 commit 67fb57d

File tree

5 files changed

+111
-21
lines changed

5 files changed

+111
-21
lines changed

.circleci/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
- checkout
1919
- run: git submodule sync
2020
- run: git submodule update --init
21+
- run: sudo apt update
22+
- run: sudo apt install -y rclone
2123
- run: pip install --user 'tox<5'
2224
- run: tox -e py310
2325
- codecov/upload

readthedocs/builds/storage.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from storages.utils import get_available_overwrite_name, safe_join
99

1010
from readthedocs.core.utils.filesystem import safe_open
11-
from readthedocs.storage.rclone import RClone
11+
from readthedocs.storage.rclone import RCloneLocal
1212

1313
log = structlog.get_logger(__name__)
1414

@@ -157,10 +157,13 @@ def sync_directory(self, source, destination):
157157

158158
@cached_property
159159
def _rclone(self):
160-
return RClone()
160+
raise NotImplementedError
161161

162162
def rclone_sync(self, source, destination):
163163
"""Sync a directory recursively to storage using rclone sync."""
164+
if destination in ("", "/"):
165+
raise SuspiciousFileOperation("Syncing all storage cannot be right")
166+
164167
return self._rclone.sync(source, destination)
165168

166169
def join(self, directory, filepath):
@@ -197,6 +200,10 @@ def __init__(self, **kwargs):
197200

198201
super().__init__(location)
199202

203+
@cached_property
204+
def _rclone(self):
205+
return RCloneLocal(location=self.location)
206+
200207
def get_available_name(self, name, max_length=None):
201208
"""
202209
A hack to overwrite by default with the FileSystemStorage.

readthedocs/rtd_tests/tests/test_build_storage.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import os
22
import shutil
33
import tempfile
4+
from pathlib import Path
45

6+
import pytest
7+
from django.core.exceptions import SuspiciousFileOperation
58
from django.test import TestCase, override_settings
69

710
from readthedocs.builds.storage import BuildMediaFileSystemStorage
@@ -118,3 +121,61 @@ def test_walk(self):
118121
self.assertEqual(top, 'files/api')
119122
self.assertCountEqual(dirs, [])
120123
self.assertCountEqual(files, ['index.html'])
124+
125+
def test_rclone_sync(self):
126+
tmp_files_dir = Path(tempfile.mkdtemp()) / "files"
127+
shutil.copytree(files_dir, tmp_files_dir, symlinks=True)
128+
storage_dir = "files"
129+
130+
tree = [
131+
("api", ["index.html"]),
132+
"api.fjson",
133+
"conf.py",
134+
"test.html",
135+
]
136+
with override_settings(DOCROOT=tmp_files_dir):
137+
self.storage.rclone_sync(tmp_files_dir, storage_dir)
138+
self.assertFileTree(storage_dir, tree)
139+
140+
tree = [
141+
("api", ["index.html"]),
142+
"conf.py",
143+
"test.html",
144+
]
145+
(tmp_files_dir / "api.fjson").unlink()
146+
with override_settings(DOCROOT=tmp_files_dir):
147+
self.storage.rclone_sync(tmp_files_dir, storage_dir)
148+
self.assertFileTree(storage_dir, tree)
149+
150+
tree = [
151+
"conf.py",
152+
"test.html",
153+
]
154+
shutil.rmtree(tmp_files_dir / "api")
155+
with override_settings(DOCROOT=tmp_files_dir):
156+
self.storage.rclone_sync(tmp_files_dir, storage_dir)
157+
self.assertFileTree(storage_dir, tree)
158+
159+
@pytest.mark.skip(
160+
"Waiting for https://github.com/readthedocs/readthedocs.org/pull/9890"
161+
)
162+
def test_rclone_sync_source_symlink(self):
163+
tmp_dir = Path(tempfile.mkdtemp())
164+
tmp_symlink_dir = Path(tempfile.mkdtemp()) / "files"
165+
tmp_symlink_dir.symlink_to(tmp_dir)
166+
167+
with override_settings(DOCROOT=tmp_dir):
168+
with pytest.raises(SuspiciousFileOperation, match="symbolic link"):
169+
self.storage.rclone_sync(tmp_symlink_dir, "files")
170+
171+
@pytest.mark.skip(
172+
"Waiting for https://github.com/readthedocs/readthedocs.org/pull/9890"
173+
)
174+
def test_rclone_sync_source_outside_docroot(self):
175+
tmp_dir = Path(tempfile.mkdtemp())
176+
tmp_docroot = Path(tempfile.mkdtemp()) / "docroot"
177+
tmp_docroot.mkdir()
178+
179+
with override_settings(DOCROOT=tmp_docroot):
180+
with pytest.raises(SuspiciousFileOperation, match="outside the docroot"):
181+
self.storage.rclone_sync(tmp_dir, "files")

readthedocs/storage/rclone.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
See https://rclone.org/docs.
55
"""
66

7-
import os
87
import subprocess
98

109
import structlog
10+
from django.utils._os import safe_join as safe_join_fs
11+
from storages.utils import safe_join
1112

1213
log = structlog.get_logger(__name__)
1314

1415

15-
class RClone:
16+
class BaseRClone:
1617

1718
"""
1819
RClone base class.
@@ -24,26 +25,30 @@ class RClone:
2425
This base class allows you to use the local file system as remote.
2526
2627
:param remote_type: You can see the full list of supported providers at
27-
https://rclone.org/#providers. Defaults to use the local filesystem
28-
(https://rclone.org/local/).
28+
https://rclone.org/#providers.
2929
:param rclone_bin: Binary name or path to the rclone binary.
3030
Defaults to ``rclone``.
3131
:param default_options: Options passed to the rclone command.
3232
:parm env_vars: Environment variables used when executing the rclone command.
3333
Useful to pass secrets to the command, since all arguments and options will be logged.
3434
"""
3535

36-
remote_type = "local"
36+
remote_type = None
3737
rclone_bin = "rclone"
3838
default_options = [
39-
# Number of file transfers to run in parallel.
39+
# Number of file transfers to run in parallel.
4040
"--transfers=8",
4141
# Skip based on checksum (if available) & size, not mod-time & size.
4242
"--checksum",
4343
"--verbose",
4444
]
4545
env_vars = {}
4646

47+
# pylint: disable=no-self-use
48+
def _build_target_path(self, path):
49+
"""Build the final target path for the remote."""
50+
return path
51+
4752
def build_target(self, path):
4853
"""
4954
Build the proper target using the current remote type.
@@ -54,6 +59,7 @@ def build_target(self, path):
5459
5560
:param path: Path to the remote target.
5661
"""
62+
path = self._build_target_path(path)
5763
return f":{self.remote_type}:{path}"
5864

5965
def execute(self, action, args, options=None):
@@ -73,8 +79,8 @@ def execute(self, action, args, options=None):
7379
"--",
7480
*args,
7581
]
76-
env = os.environ.copy()
77-
# env = {}
82+
# env = os.environ.copy()
83+
env = {}
7884
env.update(self.env_vars)
7985
log.info("Executing rclone command.", command=command)
8086
log.debug("env", env=env)
@@ -106,7 +112,28 @@ def sync(self, source, destination):
106112
return self.execute("sync", args=[source, self.build_target(destination)])
107113

108114

109-
class RCloneS3Remote(RClone):
115+
class RCloneLocal(BaseRClone):
116+
117+
"""
118+
RClone remote implementation for the local file system.
119+
120+
Used for local testing only.
121+
122+
See https://rclone.org/local/.
123+
124+
:param location: Root directory where the files will be stored.
125+
"""
126+
127+
remote_type = "local"
128+
129+
def __init__(self, location):
130+
self.location = location
131+
132+
def _build_target_path(self, path):
133+
return safe_join_fs(self.location, path)
134+
135+
136+
class RCloneS3Remote(BaseRClone):
110137

111138
"""
112139
RClone remote implementation for S3.
@@ -139,11 +166,6 @@ def __init__(
139166
acl=None,
140167
endpoint=None,
141168
):
142-
super().__init__()
143-
144-
# When using minion, the region is set to None.
145-
region = region or ""
146-
147169
# rclone S3 options passed as env vars.
148170
# https://rclone.org/s3/#standard-options.
149171
self.env_vars = {
@@ -159,7 +181,6 @@ def __init__(
159181
self.env_vars["RCLONE_S3_ENDPOINT"] = endpoint
160182
self.bucket_name = bucket_name
161183

162-
def build_target(self, path):
184+
def _build_target_path(self, path):
163185
"""Overridden to prepend the bucket name to the path."""
164-
path = f"{self.bucket_name}/{path}"
165-
return super().build_target(path)
186+
return safe_join(self.bucket_name, path)

readthedocs/storage/s3_storage.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222

2323

2424
class S3BuildMediaStorageMixin(BuildMediaStorageMixin, S3Boto3Storage):
25-
2625
@cached_property
2726
def _rclone(self):
2827
provider = "AWS"
@@ -35,7 +34,7 @@ def _rclone(self):
3534
bucket_name=self.bucket_name,
3635
access_key_id=self.access_key,
3736
secret_acces_key=self.secret_key,
38-
region=self.region_name,
37+
region=self.region_name or "",
3938
acl=self.default_acl,
4039
endpoint=self.endpoint_url,
4140
provider=provider,

0 commit comments

Comments
 (0)