Merge lp:~jtv/launchpad/db-bug-752178 into lp:launchpad/db-devel

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 10412
Proposed branch: lp:~jtv/launchpad/db-bug-752178
Merge into: lp:launchpad/db-devel
Diff against target: 313 lines (+157/-31)
6 files modified
cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases (+2/-2)
cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings (+1/-1)
cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices (+10/-7)
cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt (+2/-2)
lib/lp/archivepublisher/scripts/publish_ftpmaster.py (+52/-18)
lib/lp/archivepublisher/tests/test_publish_ftpmaster.py (+90/-1)
To merge this branch: bzr merge lp:~jtv/launchpad/db-bug-752178
Reviewer Review Type Date Requested Status
Abel Deuring (community) code Approve
Review via email: mp+56719@code.launchpad.net

Commit message

[r=adeuring][bug=752178] Escape run-parts parameters in publish-ftpmaster.

Description of the change

= Summary =

The new python replacement for cron.publish-ftpmaster passes a variable $ARCHIVEROOTS to the run-parts scripts in finalize.d. This is a list of values, but due to incorrect quoting, it would cause a second value (if any) in the list to be execute as a command instead of added to the variable.

== Proposed fix ==

Quote and escape variables where they are passed to the shell.

== Pre-implementation notes ==

Problem diagnosed by and discussed with William Grant.

== Implementation details ==

The quoting is still nowhere near perfect: what happens if there's a space in one of the configured archive roots? Still, this should be an improvement over the old unquoted shell code.

A new end-to-end test makes sure that the ARCHIVEROOTS variable is actually usable in the run-parts scripts. This should also serve as an example of how to deal with the quoting of these variables. The test deliberately uses a filename with a space in it to guard against slipups and lucky escapes.

== Tests ==

{{{
./bin/test -vvc lp.archivepublisher.tests.test_publish_ftparchive
}}}

== Demo and Q/A ==

Run cronscripts/publish-ftparchive.py on dogfood, with the run_parts_location configuration item (under [archivepublisher]) set to "cronscripts/publishing/distro-parts".

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices
  cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings
  lib/lp/archivepublisher/scripts/publish_ftpmaster.py
  cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases
  lib/lp/archivepublisher/tests/test_publish_ftpmaster.py

To post a comment you must log in.
Revision history for this message
Abel Deuring (adeuring) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases'
2--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases 2011-04-08 08:18:27 +0000
3+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases 2011-04-08 19:03:57 +0000
4@@ -5,10 +5,10 @@
5 # for this.
6 GNUPGHOME=/srv/launchpad.net/ubuntu-archive/gnupg-home
7
8+RELEASE_FILES=`find $DISTSROOT -maxdepth 2 -name Release`
9
10-RELEASE_FILES=`find "$DISTSROOT".new -maxdepth 2 -name Release`
11 DIST_UPGRADER_TARBALLS=`
12- find "$DISTSROOT".new"/*/*/dist-upgrader* -name "*.tar.gz" || true`
13+ find $DISTSROOT/*/*/dist-upgrader* -name "*.tar.gz" || true`
14
15 for CANDIDATE in $RELEASE_FILES $DIST_UPGRADER_TARBALLS
16 do
17
18=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings'
19--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings 2011-03-28 09:18:42 +0000
20+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings 2011-04-08 19:03:57 +0000
21@@ -8,4 +8,4 @@
22 # It's safe to do this since the uncompressed MD5 hashes have already been
23 # computed for inclusion in the Release files.
24
25-find "$DISTSROOT".new \( -name -o -name Sources \) -exec rm -f -- "{}" \;
26+find $DISTSROOT.new \( -name -o -name Sources \) -exec rm -f -- "{}" \;
27
28=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices'
29--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices 2011-04-08 09:15:36 +0000
30+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices 2011-04-08 19:03:57 +0000
31@@ -1,9 +1,12 @@
32 #!/bin/sh -e
33
34-echo "$(date -R): Copying the indices into place."
35-
36-INDICES=$ARCHIVEROOT/indices
37-
38-mkdir -p -- $INDICES
39-rm -f -- "$INDICES/override"
40-cp -- $OVERRIDEROOT/override.* $INDICES/
41+if [ -n "$OVERRIDEROOT" ]
42+then
43+ echo "$(date -R): Copying the indices into place."
44+
45+ INDICES=$ARCHIVEROOT/indices
46+
47+ mkdir -p -- $INDICES
48+ rm -f -- $INDICES/override
49+ cp -- $OVERRIDEROOT/override.* $INDICES/
50+fi
51
52=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt'
53--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt 2011-04-08 09:15:36 +0000
54+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt 2011-04-08 19:03:57 +0000
55@@ -18,8 +18,8 @@
56 DISTSROOT - the archive's dists root directory
57 (e.g. /srv/launchpad.net/ubuntu-archive/ubuntu/dists )
58
59-OVERRIDEROOT - the archive's overrides root directory
60-(e.g. /srv/launchpad.net/ubuntu-overrides )
61+OVERRIDEROOT - the archive's overrides root directory (primary archive only)
62+(e.g. /srv/launchpad.net/ubuntu-overrides, or the empty string for partner)
63
64 The script's PATH will be extended with the Launchpad source tree's
65 cronscripts/publishing directory.
66
67=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
68--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-04-08 09:15:36 +0000
69+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-04-08 19:03:57 +0000
70@@ -43,11 +43,46 @@
71 return boolean_text[boolean_value]
72
73
74-def compose_env_string(env):
75- """Turn a dict into a series of shell parameter assignments."""
76+def shell_quote(literal):
77+ """Escape `literal` for use in a double-quoted shell string.
78+
79+ This is a pretty naive substitution: it doesn't deal well with
80+ non-ASCII characters or special characters.
81+ """
82+ # Characters that need backslash-escaping. Do the backslash itself
83+ # first; any escapes we introduced *before* the backslash would have
84+ # their own backslashes escaped afterwards when we got to the
85+ # backslash itself.
86+ special_characters = '\\"$`\n'
87+ for escapee in special_characters:
88+ literal = literal.replace(escapee, '\\' + escapee)
89+ return '"%s"' % literal
90+
91+
92+def compose_env_string(*env_dicts):
93+ """Turn dict(s) into a series of shell parameter assignments.
94+
95+ Values in later dicts override any values with the same respective
96+ keys in earlier dicts.
97+ """
98+ env = {}
99+ for env_dict in env_dicts:
100+ env.update(env_dict)
101 return ' '.join(['='.join(pair) for pair in env.iteritems()])
102
103
104+def extend_PATH():
105+ """Produce env dict for extending $PATH.
106+
107+ Adds the Launchpad source tree's cronscripts/publishing to the
108+ existing $PATH.
109+
110+ :return: a dict suitable for passing to compose_env_string.
111+ """
112+ scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
113+ return {"PATH": '"$PATH":%s' % shell_quote(scripts_dir)}
114+
115+
116 def get_distscopyroot(archive_config):
117 """Return the distscopy root directory for `archive_config`."""
118 return archive_config.archiveroot + "-distscopy"
119@@ -256,10 +291,11 @@
120 """Execute the publish-distro hooks."""
121 archive_config = self.configs[archive.purpose]
122 env = {
123- 'ARCHIVEROOT': archive_config.archiveroot,
124- 'DISTSROOT': archive_config.distsroot,
125- 'OVERRIDEROOT': archive_config.overrideroot,
126+ 'ARCHIVEROOT': shell_quote(archive_config.archiveroot),
127+ 'DISTSROOT': shell_quote(archive_config.distsroot + ".new"),
128 }
129+ if archive_config.overrideroot is not None:
130+ env["OVERRIDEROOT"] = shell_quote(archive_config.overrideroot)
131 self.runParts('publish-distro.d', env)
132
133 def installDists(self):
134@@ -306,11 +342,10 @@
135 if not config.archivepublisher.run_commercial_compat:
136 return
137
138- self.executeShell("""
139- env PATH="$PATH:%s/cronscripts/publishing" \
140- LPCONFIG="%s" \
141- commercial-compat.sh
142- """ % (config.root, config.instance_name))
143+ env = {"LPCONFIG": shell_quote(config.instance_name)}
144+ self.executeShell(
145+ "env %s commercial-compat.sh"
146+ % compose_env_string(env, extend_PATH()))
147
148 def generateListings(self):
149 """Create ls-lR.gz listings."""
150@@ -362,22 +397,21 @@
151 if parts_dir is None:
152 self.logger.debug("Skipping run-parts %s: not configured.", parts)
153 return
154- total_env_string = ' '.join([
155- "PATH=\"$PATH:%s/cronscripts/publishing\"" % config.root,
156- compose_env_string(env),
157- ])
158+ env_string = compose_env_string(env, extend_PATH())
159 self.executeShell(
160- "env %s run-parts -- '%s'" % (total_env_string, parts_dir),
161+ "env %s run-parts -- '%s'" % (env_string, parts_dir),
162 failure=LaunchpadScriptFailure(
163 "Failure while executing run-parts %s." % parts_dir))
164
165 def runFinalizeParts(self, security_only=False):
166 """Run the finalize.d parts to finalize publication."""
167+ archive_roots = shell_quote(' '.join([
168+ archive_config.archiveroot
169+ for archive_config in self.configs.itervalues()]))
170+
171 env = {
172 'SECURITY_UPLOAD_ONLY': compose_shell_boolean(security_only),
173- 'ARCHIVEROOTS': ' '.join([
174- archive_config.archiveroot
175- for archive_config in self.configs.itervalues()]),
176+ 'ARCHIVEROOTS': archive_roots,
177 }
178 self.runParts('finalize.d', env)
179
180
181=== modified file 'lib/lp/archivepublisher/tests/test_publish_ftpmaster.py'
182--- lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-04-08 09:15:36 +0000
183+++ lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-04-08 19:03:57 +0000
184@@ -16,6 +16,7 @@
185 LaunchpadZopelessLayer,
186 ZopelessDatabaseLayer,
187 )
188+from lp.archivepublisher.config import getPubConfig
189 from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
190 from lp.registry.interfaces.pocket import (
191 PackagePublishingPocket,
192@@ -33,6 +34,7 @@
193 compose_shell_boolean,
194 find_run_parts_dir,
195 PublishFTPMaster,
196+ shell_quote,
197 )
198 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
199 from lp.testing import (
200@@ -101,7 +103,7 @@
201
202 class TestPublishFTPMasterHelpers(TestCase):
203
204- def test_compose_env_string_iterates_env(self):
205+ def test_compose_env_string_iterates_env_dict(self):
206 env = {
207 "A": "1",
208 "B": "2",
209@@ -109,6 +111,27 @@
210 env_string = compose_env_string(env)
211 self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
212
213+ def test_compose_env_string_combines_env_dicts(self):
214+ env1 = {"A": "1"}
215+ env2 = {"B": "2"}
216+ env_string = compose_env_string(env1, env2)
217+ self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
218+
219+ def test_compose_env_string_overrides_repeated_keys(self):
220+ self.assertEqual("A=2", compose_env_string({"A": "1"}, {"A": "2"}))
221+
222+ def test_shell_quote_quotes_string(self):
223+ self.assertEqual('"x"', shell_quote("x"))
224+
225+ def test_shell_quote_escapes_string(self):
226+ self.assertEqual('"\\\\"', shell_quote("\\"))
227+
228+ def test_shell_quote_does_not_escape_its_own_escapes(self):
229+ self.assertEqual('"\\$"', shell_quote("$"))
230+
231+ def test_shell_quote_escapes_entire_string(self):
232+ self.assertEqual('"\\$\\$\\$"', shell_quote("$$$"))
233+
234 def test_compose_shell_boolean_shows_True_as_yes(self):
235 self.assertEqual("yes", compose_shell_boolean(True))
236
237@@ -223,6 +246,26 @@
238 """))
239 self.addCleanup(config.pop, "commercial-compat")
240
241+ def installRunPartsScript(self, distro, parts_dir, script_code):
242+ """Set up a run-parts script, and configure it to run.
243+
244+ :param distro: The `Distribution` you're testing on. Must have
245+ a temporary directory as its publishing root directory.
246+ :param parts_dir: The run-parts subdirectory to execute:
247+ publish-distro.d or finalize.d.
248+ :param script_code: The code to go into the script.
249+ """
250+ distro_config = get_pub_config(distro)
251+ parts_base = os.path.join(distro_config.root_dir, "distro-parts")
252+ self.enableRunParts(parts_base)
253+ script_dir = os.path.join(parts_base, distro.name, parts_dir)
254+ os.makedirs(script_dir)
255+ script_path = os.path.join(script_dir, self.factory.getUniqueString())
256+ script_file = file(script_path, "w")
257+ script_file.write(script_code)
258+ script_file.close()
259+ os.chmod(script_path, 0755)
260+
261 def test_script_runs_successfully(self):
262 ubuntu = self.prepareUbuntu()
263 self.layer.txn.commit()
264@@ -698,3 +741,49 @@
265 script.runFinalizeParts = FakeMethod()
266 script.publishAllUploads()
267 self.assertEqual(1, script.runFinalizeParts.call_count)
268+
269+ def test_runFinalizeParts_quotes_archiveroots(self):
270+ # Passing ARCHIVEROOTS to the finalize.d scripts is a bit
271+ # difficult because the variable holds multiple values in a
272+ # single, double-quoted string. Escaping and quoting a sequence
273+ # of escaped and quoted items won't work.
274+ # This test establishes how a script can sanely deal with the
275+ # list. It'll probably go wrong if the configured archive root
276+ # contains spaces and such, but should work with Unix-sensible
277+ # paths.
278+ distro = self.makeDistro()
279+ self.factory.makeArchive(
280+ distribution=distro, purpose=ArchivePurpose.PARTNER)
281+ script = self.makeScript(distro)
282+ script.setUp()
283+ script.setUpDirs()
284+
285+ # Create a run-parts script that creates marker files in each of
286+ # the archive roots, and writes an expected string to them.
287+ # Doesn't write to a marker file that already exists, because it
288+ # might be a sign that the path it received is ridiculously
289+ # wrong. Don't want to go overwriting random files now do we?
290+ self.installRunPartsScript(distro, "finalize.d", dedent("""\
291+ #!/bin/sh -e
292+ MARKER_NAME="marker file"
293+ for DIRECTORY in $ARCHIVEROOTS
294+ do
295+ MARKER="$DIRECTORY/$MARKER_NAME"
296+ if [ -e "$MARKER" ]
297+ then
298+ echo "Marker file $MARKER already exists." >&2
299+ exit 1
300+ fi
301+ echo "This is an archive root." >"$MARKER"
302+ done
303+ """))
304+
305+ script.runFinalizeParts()
306+
307+ for archive in [distro.main_archive, distro.getArchive("partner")]:
308+ archive_root = getPubConfig(archive).archiveroot
309+ self.assertEqual(
310+ "This is an archive root.",
311+ self.readMarkerFile([archive_root, "marker file"]).rstrip(),
312+ "Did not find expected marker for %s."
313+ % archive.purpose.title)

Subscribers

People subscribed via source and target branches

to status/vote changes: