Skip to content

Commit 9d1e0b1

Browse files
authored
APIv2: better build command sanitization (#10025)
* APIv2: remove double `\` This made the regex to not match properly. * Test: add test for build command sanitizer * APIv2: sanitize command when it comes from cold storage * Feedback: rename function
1 parent ebea9d5 commit 9d1e0b1

File tree

4 files changed

+70
-32
lines changed

4 files changed

+70
-32
lines changed

readthedocs/api/v2/serializers.py

+4-27
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Defines serializers for each of our models."""
22

3-
import re
43

54
from allauth.socialaccount.models import SocialAccount
6-
from django.conf import settings
75
from rest_framework import serializers
86

7+
from readthedocs.api.v2.utils import normalize_build_command
98
from readthedocs.builds.models import Build, BuildCommandResult, Version
109
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
1110
from readthedocs.projects.models import Domain, Project
@@ -157,31 +156,9 @@ class BuildCommandReadOnlySerializer(BuildCommandSerializer):
157156
command = serializers.SerializerMethodField()
158157

159158
def get_command(self, obj):
160-
project_slug = obj.build.version.project.slug
161-
version_slug = obj.build.version.slug
162-
docroot = settings.DOCROOT.rstrip("/") # remove trailing '/'
163-
164-
# Remove Docker hash from DOCROOT when running it locally
165-
# DOCROOT contains the Docker container hash (e.g. b7703d1b5854).
166-
# We have to remove it from the DOCROOT it self since it changes each time
167-
# we spin up a new Docker instance locally.
168-
container_hash = "/"
169-
if settings.RTD_DOCKER_COMPOSE:
170-
docroot = re.sub("/[0-9a-z]+/?$", "", settings.DOCROOT, count=1)
171-
container_hash = "/([0-9a-z]+/)?"
172-
173-
command = obj.command
174-
regex = f"{docroot}{container_hash}{project_slug}/envs/{version_slug}(/bin/)?"
175-
command = re.sub(regex, "", command, count=1)
176-
177-
# Remove explicit variable names we use to run commands,
178-
# since users don't care about these.
179-
regex = r"^\$READTHEDOCS_VIRTUALENV_PATH/bin/"
180-
command = re.sub(regex, "", command, count=1)
181-
182-
regex = r"^\$CONDA_ENVS_PATH/\\$CONDA_DEFAULT_ENV/bin/"
183-
command = re.sub(regex, "", command, count=1)
184-
return command
159+
return normalize_build_command(
160+
obj.command, obj.build.project.slug, obj.build.version.slug
161+
)
185162

186163

187164
class BuildSerializer(serializers.ModelSerializer):

readthedocs/api/v2/utils.py

+32
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Utility functions that are used by both views and celery tasks."""
22

33
import itertools
4+
import re
45

56
import structlog
7+
from django.conf import settings
68
from rest_framework.pagination import PageNumberPagination
79

810
from readthedocs.builds.constants import (
@@ -260,6 +262,36 @@ def run_automation_rules(project, added_versions, deleted_active_versions):
260262
rule.run(version)
261263

262264

265+
def normalize_build_command(command, project_slug, version_slug):
266+
"""
267+
Sanitize the build command to be shown to users.
268+
269+
It removes internal variables and long paths to make them nicer.
270+
"""
271+
docroot = settings.DOCROOT.rstrip("/") # remove trailing '/'
272+
273+
# Remove Docker hash from DOCROOT when running it locally
274+
# DOCROOT contains the Docker container hash (e.g. b7703d1b5854).
275+
# We have to remove it from the DOCROOT it self since it changes each time
276+
# we spin up a new Docker instance locally.
277+
container_hash = "/"
278+
if settings.RTD_DOCKER_COMPOSE:
279+
docroot = re.sub("/[0-9a-z]+/?$", "", settings.DOCROOT, count=1)
280+
container_hash = "/([0-9a-z]+/)?"
281+
282+
regex = f"{docroot}{container_hash}{project_slug}/envs/{version_slug}(/bin/)?"
283+
command = re.sub(regex, "", command, count=1)
284+
285+
# Remove explicit variable names we use to run commands,
286+
# since users don't care about these.
287+
regex = r"^\$READTHEDOCS_VIRTUALENV_PATH/bin/"
288+
command = re.sub(regex, "", command, count=1)
289+
290+
regex = r"^\$CONDA_ENVS_PATH/\$CONDA_DEFAULT_ENV/bin/"
291+
command = re.sub(regex, "", command, count=1)
292+
return command
293+
294+
263295
class RemoteOrganizationPagination(PageNumberPagination):
264296
page_size = 25
265297

readthedocs/api/v2/views/model_views.py

+10
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from rest_framework.renderers import BaseRenderer, JSONRenderer
1414
from rest_framework.response import Response
1515

16+
from readthedocs.api.v2.utils import normalize_build_command
1617
from readthedocs.builds.constants import INTERNAL
1718
from readthedocs.builds.models import Build, BuildCommandResult, Version
1819
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
@@ -282,6 +283,15 @@ def retrieve(self, *args, **kwargs):
282283
try:
283284
json_resp = build_commands_storage.open(storage_path).read()
284285
data['commands'] = json.loads(json_resp)
286+
287+
# Normalize commands in the same way than when returning
288+
# them using the serializer
289+
for buildcommand in data["commands"]:
290+
buildcommand["command"] = normalize_build_command(
291+
buildcommand["command"],
292+
instance.project.slug,
293+
instance.version.slug,
294+
)
285295
except Exception:
286296
log.exception(
287297
'Failed to read build data from storage.',

readthedocs/rtd_tests/tests/test_api.py

+24-5
Original file line numberDiff line numberDiff line change
@@ -484,8 +484,20 @@ def test_make_build_commands(self):
484484
'/api/v2/command/',
485485
{
486486
"build": build["id"],
487-
"command": "echo test",
488-
"description": "foo",
487+
"command": "$CONDA_ENVS_PATH/$CONDA_DEFAULT_ENV/bin/python -m sphinx",
488+
"description": "Conda and Sphinx command",
489+
"exit_code": 0,
490+
"start_time": start_time,
491+
"end_time": end_time,
492+
},
493+
format="json",
494+
)
495+
resp = client.post(
496+
"/api/v2/command/",
497+
{
498+
"build": build["id"],
499+
"command": "$READTHEDOCS_VIRTUALENV_PATH/bin/python -m sphinx",
500+
"description": "Python and Sphinx command",
489501
"exit_code": 0,
490502
"start_time": start_time,
491503
"end_time": end_time,
@@ -496,10 +508,12 @@ def test_make_build_commands(self):
496508
resp = client.get('/api/v2/build/%s/' % build['id'])
497509
self.assertEqual(resp.status_code, 200)
498510
build = resp.data
499-
self.assertEqual(len(build["commands"]), 1)
500-
self.assertEqual(build["commands"][0]["command"], "echo test")
511+
self.assertEqual(len(build["commands"]), 2)
512+
self.assertEqual(build["commands"][0]["command"], "python -m sphinx")
501513
self.assertEqual(build["commands"][0]["run_time"], 5)
502-
self.assertEqual(build["commands"][0]["description"], "foo")
514+
self.assertEqual(
515+
build["commands"][0]["description"], "Conda and Sphinx command"
516+
)
503517
self.assertEqual(build["commands"][0]["exit_code"], 0)
504518
self.assertEqual(
505519
dateutil.parser.parse(build["commands"][0]["start_time"]), start_time
@@ -508,6 +522,11 @@ def test_make_build_commands(self):
508522
dateutil.parser.parse(build["commands"][0]["end_time"]), end_time
509523
)
510524

525+
self.assertEqual(build["commands"][1]["command"], "python -m sphinx")
526+
self.assertEqual(
527+
build["commands"][1]["description"], "Python and Sphinx command"
528+
)
529+
511530
def test_get_raw_log_success(self):
512531
project = Project.objects.get(pk=1)
513532
version = project.versions.first()

0 commit comments

Comments
 (0)