Merge ~cjwatson/launchpad:artifactory-quoting-on-deploy into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: f2464f79234e6af48c9170f1549d80b46922b9d4
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:artifactory-quoting-on-deploy
Merge into: launchpad:master
Prerequisite: ~cjwatson/launchpad:artifactory-quoting
Diff against target: 148 lines (+34/-12)
3 files modified
lib/lp/archivepublisher/artifactory.py (+7/-0)
lib/lp/archivepublisher/tests/artifactory_fixture.py (+24/-9)
lib/lp/archivepublisher/tests/test_artifactory.py (+3/-3)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+425385@code.launchpad.net

Commit message

URL-quote property values for ArtifactoryPath.deploy_file

Description of the change

`ArtifactoryPath.deploy_file` and `ArtifactoryPath.set_properties` have two entirely distinct quoting conventions. Astonishingly, the Python bindings for the former leave this entirely up to the caller without saying so. Fortunately, all that `deploy_file` needs is URL-quoting according to `urllib.parse.quote_plus`. Do this.

In the process of fixing this, I noticed that the Artifactory fixture's endpoints don't correctly unquote the URL path, so I fixed that.

Testing against a real instance:

  >>> (testpath / 'pool' / 'base-files_12ubuntu4.1_amd64.deb').deploy_file('/home/cjwatson/base-files_12ubuntu4.1_amd64.deb', parameters={'unquoted': ['foo+bar;baz'], 'backslashquoted': [r'foo\+bar\;baz'], 'urlquoted': [quote_plus('foo+bar;baz')]})
  >>> (testpath / 'pool' / 'base-files_12ubuntu4.1_amd64.deb').properties
  {'unquoted': ['foo bar'], 'backslashquoted': ['foo\\ bar\\'], 'urlquoted': ['foo+bar;baz'], 'baz': ['']}

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/archivepublisher/artifactory.py b/lib/lp/archivepublisher/artifactory.py
index 611100c..2268d71 100644
--- a/lib/lp/archivepublisher/artifactory.py
+++ b/lib/lp/archivepublisher/artifactory.py
@@ -13,6 +13,7 @@ import tempfile
13from collections import defaultdict13from collections import defaultdict
14from pathlib import Path, PurePath14from pathlib import Path, PurePath
15from typing import List, Optional15from typing import List, Optional
16from urllib.parse import quote_plus
1617
17import requests18import requests
18from artifactory import ArtifactoryPath19from artifactory import ArtifactoryPath
@@ -271,6 +272,12 @@ class ArtifactoryPoolEntry:
271 properties = self.calculateProperties(272 properties = self.calculateProperties(
272 self.makeReleaseID(self.pub_file), []273 self.makeReleaseID(self.pub_file), []
273 )274 )
275 # Property values must be URL-quoted for
276 # `ArtifactoryPath.deploy_file`; it does not handle that itself.
277 properties = {
278 key: [quote_plus(v) for v in value]
279 for key, value in properties.items()
280 }
274 fd, name = tempfile.mkstemp(prefix="temp-download.")281 fd, name = tempfile.mkstemp(prefix="temp-download.")
275 f = os.fdopen(fd, "wb")282 f = os.fdopen(fd, "wb")
276 try:283 try:
diff --git a/lib/lp/archivepublisher/tests/artifactory_fixture.py b/lib/lp/archivepublisher/tests/artifactory_fixture.py
index 93bcf41..13a9ab8 100644
--- a/lib/lp/archivepublisher/tests/artifactory_fixture.py
+++ b/lib/lp/archivepublisher/tests/artifactory_fixture.py
@@ -11,9 +11,10 @@ import fnmatch
11import hashlib11import hashlib
12import json12import json
13import re13import re
14from collections import defaultdict
14from datetime import datetime, timezone15from datetime import datetime, timezone
15from pathlib import Path16from pathlib import Path
16from urllib.parse import parse_qs, unquote, urlparse17from urllib.parse import parse_qs, unquote, unquote_plus, urlparse
1718
18import responses19import responses
19from fixtures import Fixture20from fixtures import Fixture
@@ -88,7 +89,7 @@ class FakeArtifactoryFixture(Fixture):
8889
89 def _handle_download(self, request):90 def _handle_download(self, request):
90 """Handle a request to download an existing file."""91 """Handle a request to download an existing file."""
91 path = urlparse(request.url[len(self.repo_url) :]).path92 path = unquote(urlparse(request.url[len(self.repo_url) :]).path)
92 if path in self._fs and "size" in self._fs[path]:93 if path in self._fs and "size" in self._fs[path]:
93 return (94 return (
94 200,95 200,
@@ -101,7 +102,7 @@ class FakeArtifactoryFixture(Fixture):
101 def _handle_stat(self, request):102 def _handle_stat(self, request):
102 """Handle a request to stat an existing file."""103 """Handle a request to stat an existing file."""
103 parsed_url = urlparse(request.url[len(self.api_url) :])104 parsed_url = urlparse(request.url[len(self.api_url) :])
104 path = parsed_url.path105 path = unquote(parsed_url.path)
105 if path in self._fs:106 if path in self._fs:
106 stat = {"repo": self.repository_name, "path": path}107 stat = {"repo": self.repository_name, "path": path}
107 stat.update(self._fs[path])108 stat.update(self._fs[path])
@@ -134,8 +135,22 @@ class FakeArtifactoryFixture(Fixture):
134 """135 """
135 return re.sub(r"\\([,|=;])", r"\1", unquote(text))136 return re.sub(r"\\([,|=;])", r"\1", unquote(text))
136137
138 def _decode_matrix_parameters(self, encoded):
139 """Decode matrix parameters that were encoded as part of a request.
140
141 `ArtifactoryPath.deploy` encodes properties like this.
142 """
143 properties = defaultdict(list)
144 for param in encoded.split(";"):
145 key, value = param.split("=", 1)
146 properties[unquote_plus(key)].append(unquote_plus(value))
147 return properties
148
137 def _decode_properties(self, encoded):149 def _decode_properties(self, encoded):
138 """Decode properties that were encoded as part of a request."""150 """Decode properties that were encoded as part of a request.
151
152 `ArtifactoryPath.set_properties` encodes properties like this.
153 """
139 properties = {}154 properties = {}
140 for param in self._split(";", encoded):155 for param in self._split(";", encoded):
141 key, value = re.match(r"((?:\\[,|=;]|[^=])+)=(.*)", param).groups()156 key, value = re.match(r"((?:\\[,|=;]|[^=])+)=(.*)", param).groups()
@@ -155,11 +170,11 @@ class FakeArtifactoryFixture(Fixture):
155 else:170 else:
156 params = ""171 params = ""
157 parsed_url = urlparse(url)172 parsed_url = urlparse(url)
158 path = parsed_url.path173 path = unquote(parsed_url.path)
159 if path.endswith("/"):174 if path.endswith("/"):
160 self.add_dir(path.rstrip("/"))175 self.add_dir(path.rstrip("/"))
161 elif path.rsplit("/", 1)[0] in self._fs:176 elif path.rsplit("/", 1)[0] in self._fs:
162 properties = self._decode_properties(params)177 properties = self._decode_matrix_parameters(params)
163 self.add_file(178 self.add_file(
164 path,179 path,
165 request.body,180 request.body,
@@ -171,7 +186,7 @@ class FakeArtifactoryFixture(Fixture):
171 def _handle_set_properties(self, request):186 def _handle_set_properties(self, request):
172 """Handle a request to set properties on an existing file."""187 """Handle a request to set properties on an existing file."""
173 parsed_url = urlparse(request.url[len(self.api_url) :])188 parsed_url = urlparse(request.url[len(self.api_url) :])
174 path = parsed_url.path189 path = unquote(parsed_url.path)
175 if path in self._fs:190 if path in self._fs:
176 query = parse_qs(parsed_url.query)191 query = parse_qs(parsed_url.query)
177 properties = self._decode_properties(query["properties"][0])192 properties = self._decode_properties(query["properties"][0])
@@ -183,7 +198,7 @@ class FakeArtifactoryFixture(Fixture):
183 def _handle_delete_properties(self, request):198 def _handle_delete_properties(self, request):
184 """Handle a request to delete properties from an existing file."""199 """Handle a request to delete properties from an existing file."""
185 parsed_url = urlparse(request.url[len(self.api_url) :])200 parsed_url = urlparse(request.url[len(self.api_url) :])
186 path = parsed_url.path201 path = unquote(parsed_url.path)
187 if path in self._fs:202 if path in self._fs:
188 query = parse_qs(parsed_url.query)203 query = parse_qs(parsed_url.query)
189 for key in query["properties"][0].split(","):204 for key in query["properties"][0].split(","):
@@ -265,7 +280,7 @@ class FakeArtifactoryFixture(Fixture):
265280
266 def _handle_delete(self, request):281 def _handle_delete(self, request):
267 """Handle a request to delete an existing file."""282 """Handle a request to delete an existing file."""
268 path = urlparse(request.url[len(self.repo_url) :]).path283 path = unquote(urlparse(request.url[len(self.repo_url) :]).path)
269 if not path.endswith("/") and path in self._fs:284 if not path.endswith("/") and path in self._fs:
270 self.remove_file(path)285 self.remove_file(path)
271 return 200, {}, ""286 return 200, {}, ""
diff --git a/lib/lp/archivepublisher/tests/test_artifactory.py b/lib/lp/archivepublisher/tests/test_artifactory.py
index 0e013e5..196571d 100644
--- a/lib/lp/archivepublisher/tests/test_artifactory.py
+++ b/lib/lp/archivepublisher/tests/test_artifactory.py
@@ -131,8 +131,8 @@ class TestArtifactoryPool(TestCase):
131 foo = ArtifactoryPoolTestingFile(131 foo = ArtifactoryPoolTestingFile(
132 pool=pool,132 pool=pool,
133 source_name="foo",133 source_name="foo",
134 source_version="1.0",134 source_version="1.0+1",
135 filename="foo-1.0.deb",135 filename="foo-1.0+1.deb",
136 release_type=FakeReleaseType.BINARY,136 release_type=FakeReleaseType.BINARY,
137 release_id=1,137 release_id=1,
138 )138 )
@@ -144,7 +144,7 @@ class TestArtifactoryPool(TestCase):
144 {144 {
145 "launchpad.release-id": ["binary:1"],145 "launchpad.release-id": ["binary:1"],
146 "launchpad.source-name": ["foo"],146 "launchpad.source-name": ["foo"],
147 "launchpad.source-version": ["1.0"],147 "launchpad.source-version": ["1.0+1"],
148 },148 },
149 foo.getProperties(),149 foo.getProperties(),
150 )150 )

Subscribers

People subscribed via source and target branches

to status/vote changes: