Repository: getsentry/sentry

Issue:
Release Endpoints Version Field Encoding
Currently due to a bug we require clients to multi encode incoming data in the version field.  The decoding happens in these places:
- first level decode in the webserver which decodes for the routing
- second decode is directly in the project endpoint

This means a client needs to double encode the data to get to what the code originally expects.

The original unquoting was done as an attempt to get slashes through the system but unfortunatley they cannot be carried in url path components.  A `%2f` gets resolved to `/` early on to support matching on paths and there is little we can do about that.  Some servers support passing through unquoted bytes but only if no high level matching has been performed.  However even in that case the django routing system requires unquoted paths to support matching which again would kill the slash.

Proposals:
- send the data into a query string, there all bytes survive
- figure out which wsgi servers we support and try to see if we can get to raw bytes for all of them, then build a reconciliation system to get to the raw bytes
- do greedy matching where we allow slashes to appear as part of the parameter.  eg: `/foo/<bar>` where `bar` can contain slashes itself.



Discussion:
Thoughts @dcramer?

I'm not super thrilled about our inability to support this. Using a query string would require literally every endpoint to be changed / duplicated.

If it comes down to using a query string or disallowing slashes, I'm going to vote the latter.

I'm good with just disallowing slashes.

That's fine. We may want to query on what already exists in the system and make sure we're not breaking anyones data. We also need to handle this in the Release auto creation code.

I'm going to remove the bad unquotes for the moment. That will at least make the current endpoints work if you are not using slashes and then separately disallow slashes on new versions.


Resolving patch:
diff --git a/src/sentry/api/endpoints/release_details.py b/src/sentry/api/endpoints/release_details.py
--- a/src/sentry/api/endpoints/release_details.py
+++ b/src/sentry/api/endpoints/release_details.py
@@ -2,7 +2,6 @@
 
 from rest_framework import serializers
 from rest_framework.response import Response
-from urllib import unquote
 
 from sentry.api.base import DocSection
 from sentry.api.bases.project import ProjectEndpoint
@@ -75,8 +74,6 @@ def get(self, request, project, version):
         :pparam string version: the version identifier of the release.
         :auth: required
         """
-        version = unquote(version)
-
         try:
             release = Release.objects.get(
                 project=project,
@@ -114,8 +111,6 @@ def put(self, request, project, version):
         :auth: required
         """
         # TODO(dcramer): handle Activity creation
-        version = unquote(version)
-
         try:
             release = Release.objects.get(
                 project=project,
@@ -161,8 +156,6 @@ def delete(self, request, project, version):
         :pparam string version: the version identifier of the release.
         :auth: required
         """
-        version = unquote(version)
-
         try:
             release = Release.objects.get(
                 project=project,
diff --git a/src/sentry/api/endpoints/release_file_details.py b/src/sentry/api/endpoints/release_file_details.py
--- a/src/sentry/api/endpoints/release_file_details.py
+++ b/src/sentry/api/endpoints/release_file_details.py
@@ -2,7 +2,6 @@
 
 from rest_framework import serializers
 from rest_framework.response import Response
-from urllib import unquote
 
 from sentry.api.base import DocSection
 from sentry.api.bases.project import ProjectEndpoint
@@ -87,8 +86,6 @@ def get(self, request, project, version, file_id):
         :pparam string file_id: the ID of the file to retrieve.
         :auth: required
         """
-        version = unquote(version)
-
         release = Release.objects.get(
             project=project,
             version=version,
@@ -118,8 +115,6 @@ def put(self, request, project, version, file_id):
         :param string name: the new name of the file.
         :auth: required
         """
-        version = unquote(version)
-
         release = Release.objects.get(
             project=project,
             version=version,
@@ -159,8 +154,6 @@ def delete(self, request, project, version, file_id):
         :pparam string file_id: the ID of the file to delete.
         :auth: required
         """
-        version = unquote(version)
-
         release = Release.objects.get(
             project=project,
             version=version,
diff --git a/src/sentry/api/endpoints/release_files.py b/src/sentry/api/endpoints/release_files.py
--- a/src/sentry/api/endpoints/release_files.py
+++ b/src/sentry/api/endpoints/release_files.py
@@ -1,7 +1,6 @@
 from __future__ import absolute_import
 
 from StringIO import StringIO
-from urllib import unquote
 
 from django.db import IntegrityError, transaction
 from rest_framework.negotiation import DefaultContentNegotiation
@@ -82,8 +81,6 @@ def get(self, request, project, version):
         :pparam string version: the version identifier of the release.
         :auth: required
         """
-        version = unquote(version)
-
         try:
             release = Release.objects.get(
                 project=project,
@@ -133,8 +130,6 @@ def post(self, request, project, version):
                               type.
         :auth: required
         """
-        version = unquote(version)
-
         try:
             release = Release.objects.get(
                 project=project,


Test patch:
diff --git a/tests/sentry/api/endpoints/test_release_details.py b/tests/sentry/api/endpoints/test_release_details.py
--- a/tests/sentry/api/endpoints/test_release_details.py
+++ b/tests/sentry/api/endpoints/test_release_details.py
@@ -26,25 +26,6 @@ def test_simple(self):
         assert response.status_code == 200, response.content
         assert response.data['version'] == release.version
 
-    def test_with_slash_in_version(self):
-        self.login_as(user=self.user)
-
-        project = self.create_project(name='foo')
-        release = Release.objects.create(
-            project=project,
-            version='release/1',
-        )
-
-        url = reverse('sentry-api-0-release-details', kwargs={
-            'organization_slug': project.organization.slug,
-            'project_slug': project.slug,
-            'version': 'release%2F1',
-        })
-        response = self.client.get(url)
-
-        assert response.status_code == 200, response.content
-        assert response.data['version'] == release.version
-
 
 class UpdateReleaseDetailsTest(APITestCase):
     def test_simple(self):