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
1diff --git a/lib/lp/archivepublisher/artifactory.py b/lib/lp/archivepublisher/artifactory.py
2index 611100c..2268d71 100644
3--- a/lib/lp/archivepublisher/artifactory.py
4+++ b/lib/lp/archivepublisher/artifactory.py
5@@ -13,6 +13,7 @@ import tempfile
6 from collections import defaultdict
7 from pathlib import Path, PurePath
8 from typing import List, Optional
9+from urllib.parse import quote_plus
10
11 import requests
12 from artifactory import ArtifactoryPath
13@@ -271,6 +272,12 @@ class ArtifactoryPoolEntry:
14 properties = self.calculateProperties(
15 self.makeReleaseID(self.pub_file), []
16 )
17+ # Property values must be URL-quoted for
18+ # `ArtifactoryPath.deploy_file`; it does not handle that itself.
19+ properties = {
20+ key: [quote_plus(v) for v in value]
21+ for key, value in properties.items()
22+ }
23 fd, name = tempfile.mkstemp(prefix="temp-download.")
24 f = os.fdopen(fd, "wb")
25 try:
26diff --git a/lib/lp/archivepublisher/tests/artifactory_fixture.py b/lib/lp/archivepublisher/tests/artifactory_fixture.py
27index 93bcf41..13a9ab8 100644
28--- a/lib/lp/archivepublisher/tests/artifactory_fixture.py
29+++ b/lib/lp/archivepublisher/tests/artifactory_fixture.py
30@@ -11,9 +11,10 @@ import fnmatch
31 import hashlib
32 import json
33 import re
34+from collections import defaultdict
35 from datetime import datetime, timezone
36 from pathlib import Path
37-from urllib.parse import parse_qs, unquote, urlparse
38+from urllib.parse import parse_qs, unquote, unquote_plus, urlparse
39
40 import responses
41 from fixtures import Fixture
42@@ -88,7 +89,7 @@ class FakeArtifactoryFixture(Fixture):
43
44 def _handle_download(self, request):
45 """Handle a request to download an existing file."""
46- path = urlparse(request.url[len(self.repo_url) :]).path
47+ path = unquote(urlparse(request.url[len(self.repo_url) :]).path)
48 if path in self._fs and "size" in self._fs[path]:
49 return (
50 200,
51@@ -101,7 +102,7 @@ class FakeArtifactoryFixture(Fixture):
52 def _handle_stat(self, request):
53 """Handle a request to stat an existing file."""
54 parsed_url = urlparse(request.url[len(self.api_url) :])
55- path = parsed_url.path
56+ path = unquote(parsed_url.path)
57 if path in self._fs:
58 stat = {"repo": self.repository_name, "path": path}
59 stat.update(self._fs[path])
60@@ -134,8 +135,22 @@ class FakeArtifactoryFixture(Fixture):
61 """
62 return re.sub(r"\\([,|=;])", r"\1", unquote(text))
63
64+ def _decode_matrix_parameters(self, encoded):
65+ """Decode matrix parameters that were encoded as part of a request.
66+
67+ `ArtifactoryPath.deploy` encodes properties like this.
68+ """
69+ properties = defaultdict(list)
70+ for param in encoded.split(";"):
71+ key, value = param.split("=", 1)
72+ properties[unquote_plus(key)].append(unquote_plus(value))
73+ return properties
74+
75 def _decode_properties(self, encoded):
76- """Decode properties that were encoded as part of a request."""
77+ """Decode properties that were encoded as part of a request.
78+
79+ `ArtifactoryPath.set_properties` encodes properties like this.
80+ """
81 properties = {}
82 for param in self._split(";", encoded):
83 key, value = re.match(r"((?:\\[,|=;]|[^=])+)=(.*)", param).groups()
84@@ -155,11 +170,11 @@ class FakeArtifactoryFixture(Fixture):
85 else:
86 params = ""
87 parsed_url = urlparse(url)
88- path = parsed_url.path
89+ path = unquote(parsed_url.path)
90 if path.endswith("/"):
91 self.add_dir(path.rstrip("/"))
92 elif path.rsplit("/", 1)[0] in self._fs:
93- properties = self._decode_properties(params)
94+ properties = self._decode_matrix_parameters(params)
95 self.add_file(
96 path,
97 request.body,
98@@ -171,7 +186,7 @@ class FakeArtifactoryFixture(Fixture):
99 def _handle_set_properties(self, request):
100 """Handle a request to set properties on an existing file."""
101 parsed_url = urlparse(request.url[len(self.api_url) :])
102- path = parsed_url.path
103+ path = unquote(parsed_url.path)
104 if path in self._fs:
105 query = parse_qs(parsed_url.query)
106 properties = self._decode_properties(query["properties"][0])
107@@ -183,7 +198,7 @@ class FakeArtifactoryFixture(Fixture):
108 def _handle_delete_properties(self, request):
109 """Handle a request to delete properties from an existing file."""
110 parsed_url = urlparse(request.url[len(self.api_url) :])
111- path = parsed_url.path
112+ path = unquote(parsed_url.path)
113 if path in self._fs:
114 query = parse_qs(parsed_url.query)
115 for key in query["properties"][0].split(","):
116@@ -265,7 +280,7 @@ class FakeArtifactoryFixture(Fixture):
117
118 def _handle_delete(self, request):
119 """Handle a request to delete an existing file."""
120- path = urlparse(request.url[len(self.repo_url) :]).path
121+ path = unquote(urlparse(request.url[len(self.repo_url) :]).path)
122 if not path.endswith("/") and path in self._fs:
123 self.remove_file(path)
124 return 200, {}, ""
125diff --git a/lib/lp/archivepublisher/tests/test_artifactory.py b/lib/lp/archivepublisher/tests/test_artifactory.py
126index 0e013e5..196571d 100644
127--- a/lib/lp/archivepublisher/tests/test_artifactory.py
128+++ b/lib/lp/archivepublisher/tests/test_artifactory.py
129@@ -131,8 +131,8 @@ class TestArtifactoryPool(TestCase):
130 foo = ArtifactoryPoolTestingFile(
131 pool=pool,
132 source_name="foo",
133- source_version="1.0",
134- filename="foo-1.0.deb",
135+ source_version="1.0+1",
136+ filename="foo-1.0+1.deb",
137 release_type=FakeReleaseType.BINARY,
138 release_id=1,
139 )
140@@ -144,7 +144,7 @@ class TestArtifactoryPool(TestCase):
141 {
142 "launchpad.release-id": ["binary:1"],
143 "launchpad.source-name": ["foo"],
144- "launchpad.source-version": ["1.0"],
145+ "launchpad.source-version": ["1.0+1"],
146 },
147 foo.getProperties(),
148 )

Subscribers

People subscribed via source and target branches

to status/vote changes: