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
=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases'
--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases 2011-04-08 08:18:27 +0000
+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/10-sign-releases 2011-04-08 19:03:57 +0000
@@ -5,10 +5,10 @@
5# for this.5# for this.
6GNUPGHOME=/srv/launchpad.net/ubuntu-archive/gnupg-home6GNUPGHOME=/srv/launchpad.net/ubuntu-archive/gnupg-home
77
8RELEASE_FILES=`find $DISTSROOT -maxdepth 2 -name Release`
89
9RELEASE_FILES=`find "$DISTSROOT".new -maxdepth 2 -name Release`
10DIST_UPGRADER_TARBALLS=`10DIST_UPGRADER_TARBALLS=`
11 find "$DISTSROOT".new"/*/*/dist-upgrader* -name "*.tar.gz" || true`11 find $DISTSROOT/*/*/dist-upgrader* -name "*.tar.gz" || true`
1212
13for CANDIDATE in $RELEASE_FILES $DIST_UPGRADER_TARBALLS13for CANDIDATE in $RELEASE_FILES $DIST_UPGRADER_TARBALLS
14do14do
1515
=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings'
--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings 2011-03-28 09:18:42 +0000
+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/20-remove-uncompressed-listings 2011-04-08 19:03:57 +0000
@@ -8,4 +8,4 @@
8# It's safe to do this since the uncompressed MD5 hashes have already been8# It's safe to do this since the uncompressed MD5 hashes have already been
9# computed for inclusion in the Release files.9# computed for inclusion in the Release files.
1010
11find "$DISTSROOT".new \( -name -o -name Sources \) -exec rm -f -- "{}" \;11find $DISTSROOT.new \( -name -o -name Sources \) -exec rm -f -- "{}" \;
1212
=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices'
--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices 2011-04-08 09:15:36 +0000
+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/30-copy-indices 2011-04-08 19:03:57 +0000
@@ -1,9 +1,12 @@
1#!/bin/sh -e1#!/bin/sh -e
22
3echo "$(date -R): Copying the indices into place."3if [ -n "$OVERRIDEROOT" ]
44then
5INDICES=$ARCHIVEROOT/indices5 echo "$(date -R): Copying the indices into place."
66
7mkdir -p -- $INDICES7 INDICES=$ARCHIVEROOT/indices
8rm -f -- "$INDICES/override"8
9cp -- $OVERRIDEROOT/override.* $INDICES/9 mkdir -p -- $INDICES
10 rm -f -- $INDICES/override
11 cp -- $OVERRIDEROOT/override.* $INDICES/
12fi
1013
=== modified file 'cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt'
--- cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt 2011-04-08 09:15:36 +0000
+++ cronscripts/publishing/distro-parts/ubuntu/publish-distro.d/README.txt 2011-04-08 19:03:57 +0000
@@ -18,8 +18,8 @@
18DISTSROOT - the archive's dists root directory18DISTSROOT - the archive's dists root directory
19(e.g. /srv/launchpad.net/ubuntu-archive/ubuntu/dists )19(e.g. /srv/launchpad.net/ubuntu-archive/ubuntu/dists )
2020
21OVERRIDEROOT - the archive's overrides root directory21OVERRIDEROOT - the archive's overrides root directory (primary archive only)
22(e.g. /srv/launchpad.net/ubuntu-overrides )22(e.g. /srv/launchpad.net/ubuntu-overrides, or the empty string for partner)
2323
24The script's PATH will be extended with the Launchpad source tree's24The script's PATH will be extended with the Launchpad source tree's
25cronscripts/publishing directory.25cronscripts/publishing directory.
2626
=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-04-08 09:15:36 +0000
+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-04-08 19:03:57 +0000
@@ -43,11 +43,46 @@
43 return boolean_text[boolean_value]43 return boolean_text[boolean_value]
4444
4545
46def compose_env_string(env):46def shell_quote(literal):
47 """Turn a dict into a series of shell parameter assignments."""47 """Escape `literal` for use in a double-quoted shell string.
48
49 This is a pretty naive substitution: it doesn't deal well with
50 non-ASCII characters or special characters.
51 """
52 # Characters that need backslash-escaping. Do the backslash itself
53 # first; any escapes we introduced *before* the backslash would have
54 # their own backslashes escaped afterwards when we got to the
55 # backslash itself.
56 special_characters = '\\"$`\n'
57 for escapee in special_characters:
58 literal = literal.replace(escapee, '\\' + escapee)
59 return '"%s"' % literal
60
61
62def compose_env_string(*env_dicts):
63 """Turn dict(s) into a series of shell parameter assignments.
64
65 Values in later dicts override any values with the same respective
66 keys in earlier dicts.
67 """
68 env = {}
69 for env_dict in env_dicts:
70 env.update(env_dict)
48 return ' '.join(['='.join(pair) for pair in env.iteritems()])71 return ' '.join(['='.join(pair) for pair in env.iteritems()])
4972
5073
74def extend_PATH():
75 """Produce env dict for extending $PATH.
76
77 Adds the Launchpad source tree's cronscripts/publishing to the
78 existing $PATH.
79
80 :return: a dict suitable for passing to compose_env_string.
81 """
82 scripts_dir = os.path.join(config.root, "cronscripts", "publishing")
83 return {"PATH": '"$PATH":%s' % shell_quote(scripts_dir)}
84
85
51def get_distscopyroot(archive_config):86def get_distscopyroot(archive_config):
52 """Return the distscopy root directory for `archive_config`."""87 """Return the distscopy root directory for `archive_config`."""
53 return archive_config.archiveroot + "-distscopy"88 return archive_config.archiveroot + "-distscopy"
@@ -256,10 +291,11 @@
256 """Execute the publish-distro hooks."""291 """Execute the publish-distro hooks."""
257 archive_config = self.configs[archive.purpose]292 archive_config = self.configs[archive.purpose]
258 env = {293 env = {
259 'ARCHIVEROOT': archive_config.archiveroot,294 'ARCHIVEROOT': shell_quote(archive_config.archiveroot),
260 'DISTSROOT': archive_config.distsroot,295 'DISTSROOT': shell_quote(archive_config.distsroot + ".new"),
261 'OVERRIDEROOT': archive_config.overrideroot,
262 }296 }
297 if archive_config.overrideroot is not None:
298 env["OVERRIDEROOT"] = shell_quote(archive_config.overrideroot)
263 self.runParts('publish-distro.d', env)299 self.runParts('publish-distro.d', env)
264300
265 def installDists(self):301 def installDists(self):
@@ -306,11 +342,10 @@
306 if not config.archivepublisher.run_commercial_compat:342 if not config.archivepublisher.run_commercial_compat:
307 return343 return
308344
309 self.executeShell("""345 env = {"LPCONFIG": shell_quote(config.instance_name)}
310 env PATH="$PATH:%s/cronscripts/publishing" \346 self.executeShell(
311 LPCONFIG="%s" \347 "env %s commercial-compat.sh"
312 commercial-compat.sh348 % compose_env_string(env, extend_PATH()))
313 """ % (config.root, config.instance_name))
314349
315 def generateListings(self):350 def generateListings(self):
316 """Create ls-lR.gz listings."""351 """Create ls-lR.gz listings."""
@@ -362,22 +397,21 @@
362 if parts_dir is None:397 if parts_dir is None:
363 self.logger.debug("Skipping run-parts %s: not configured.", parts)398 self.logger.debug("Skipping run-parts %s: not configured.", parts)
364 return399 return
365 total_env_string = ' '.join([400 env_string = compose_env_string(env, extend_PATH())
366 "PATH=\"$PATH:%s/cronscripts/publishing\"" % config.root,
367 compose_env_string(env),
368 ])
369 self.executeShell(401 self.executeShell(
370 "env %s run-parts -- '%s'" % (total_env_string, parts_dir),402 "env %s run-parts -- '%s'" % (env_string, parts_dir),
371 failure=LaunchpadScriptFailure(403 failure=LaunchpadScriptFailure(
372 "Failure while executing run-parts %s." % parts_dir))404 "Failure while executing run-parts %s." % parts_dir))
373405
374 def runFinalizeParts(self, security_only=False):406 def runFinalizeParts(self, security_only=False):
375 """Run the finalize.d parts to finalize publication."""407 """Run the finalize.d parts to finalize publication."""
408 archive_roots = shell_quote(' '.join([
409 archive_config.archiveroot
410 for archive_config in self.configs.itervalues()]))
411
376 env = {412 env = {
377 'SECURITY_UPLOAD_ONLY': compose_shell_boolean(security_only),413 'SECURITY_UPLOAD_ONLY': compose_shell_boolean(security_only),
378 'ARCHIVEROOTS': ' '.join([414 'ARCHIVEROOTS': archive_roots,
379 archive_config.archiveroot
380 for archive_config in self.configs.itervalues()]),
381 }415 }
382 self.runParts('finalize.d', env)416 self.runParts('finalize.d', env)
383417
384418
=== modified file 'lib/lp/archivepublisher/tests/test_publish_ftpmaster.py'
--- lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-04-08 09:15:36 +0000
+++ lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-04-08 19:03:57 +0000
@@ -16,6 +16,7 @@
16 LaunchpadZopelessLayer,16 LaunchpadZopelessLayer,
17 ZopelessDatabaseLayer,17 ZopelessDatabaseLayer,
18 )18 )
19from lp.archivepublisher.config import getPubConfig
19from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet20from lp.archivepublisher.interfaces.publisherconfig import IPublisherConfigSet
20from lp.registry.interfaces.pocket import (21from lp.registry.interfaces.pocket import (
21 PackagePublishingPocket,22 PackagePublishingPocket,
@@ -33,6 +34,7 @@
33 compose_shell_boolean,34 compose_shell_boolean,
34 find_run_parts_dir,35 find_run_parts_dir,
35 PublishFTPMaster,36 PublishFTPMaster,
37 shell_quote,
36 )38 )
37from lp.soyuz.tests.test_publishing import SoyuzTestPublisher39from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
38from lp.testing import (40from lp.testing import (
@@ -101,7 +103,7 @@
101103
102class TestPublishFTPMasterHelpers(TestCase):104class TestPublishFTPMasterHelpers(TestCase):
103105
104 def test_compose_env_string_iterates_env(self):106 def test_compose_env_string_iterates_env_dict(self):
105 env = {107 env = {
106 "A": "1",108 "A": "1",
107 "B": "2",109 "B": "2",
@@ -109,6 +111,27 @@
109 env_string = compose_env_string(env)111 env_string = compose_env_string(env)
110 self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])112 self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
111113
114 def test_compose_env_string_combines_env_dicts(self):
115 env1 = {"A": "1"}
116 env2 = {"B": "2"}
117 env_string = compose_env_string(env1, env2)
118 self.assertIn(env_string, ["A=1 B=2", "B=2 A=1"])
119
120 def test_compose_env_string_overrides_repeated_keys(self):
121 self.assertEqual("A=2", compose_env_string({"A": "1"}, {"A": "2"}))
122
123 def test_shell_quote_quotes_string(self):
124 self.assertEqual('"x"', shell_quote("x"))
125
126 def test_shell_quote_escapes_string(self):
127 self.assertEqual('"\\\\"', shell_quote("\\"))
128
129 def test_shell_quote_does_not_escape_its_own_escapes(self):
130 self.assertEqual('"\\$"', shell_quote("$"))
131
132 def test_shell_quote_escapes_entire_string(self):
133 self.assertEqual('"\\$\\$\\$"', shell_quote("$$$"))
134
112 def test_compose_shell_boolean_shows_True_as_yes(self):135 def test_compose_shell_boolean_shows_True_as_yes(self):
113 self.assertEqual("yes", compose_shell_boolean(True))136 self.assertEqual("yes", compose_shell_boolean(True))
114137
@@ -223,6 +246,26 @@
223 """))246 """))
224 self.addCleanup(config.pop, "commercial-compat")247 self.addCleanup(config.pop, "commercial-compat")
225248
249 def installRunPartsScript(self, distro, parts_dir, script_code):
250 """Set up a run-parts script, and configure it to run.
251
252 :param distro: The `Distribution` you're testing on. Must have
253 a temporary directory as its publishing root directory.
254 :param parts_dir: The run-parts subdirectory to execute:
255 publish-distro.d or finalize.d.
256 :param script_code: The code to go into the script.
257 """
258 distro_config = get_pub_config(distro)
259 parts_base = os.path.join(distro_config.root_dir, "distro-parts")
260 self.enableRunParts(parts_base)
261 script_dir = os.path.join(parts_base, distro.name, parts_dir)
262 os.makedirs(script_dir)
263 script_path = os.path.join(script_dir, self.factory.getUniqueString())
264 script_file = file(script_path, "w")
265 script_file.write(script_code)
266 script_file.close()
267 os.chmod(script_path, 0755)
268
226 def test_script_runs_successfully(self):269 def test_script_runs_successfully(self):
227 ubuntu = self.prepareUbuntu()270 ubuntu = self.prepareUbuntu()
228 self.layer.txn.commit()271 self.layer.txn.commit()
@@ -698,3 +741,49 @@
698 script.runFinalizeParts = FakeMethod()741 script.runFinalizeParts = FakeMethod()
699 script.publishAllUploads()742 script.publishAllUploads()
700 self.assertEqual(1, script.runFinalizeParts.call_count)743 self.assertEqual(1, script.runFinalizeParts.call_count)
744
745 def test_runFinalizeParts_quotes_archiveroots(self):
746 # Passing ARCHIVEROOTS to the finalize.d scripts is a bit
747 # difficult because the variable holds multiple values in a
748 # single, double-quoted string. Escaping and quoting a sequence
749 # of escaped and quoted items won't work.
750 # This test establishes how a script can sanely deal with the
751 # list. It'll probably go wrong if the configured archive root
752 # contains spaces and such, but should work with Unix-sensible
753 # paths.
754 distro = self.makeDistro()
755 self.factory.makeArchive(
756 distribution=distro, purpose=ArchivePurpose.PARTNER)
757 script = self.makeScript(distro)
758 script.setUp()
759 script.setUpDirs()
760
761 # Create a run-parts script that creates marker files in each of
762 # the archive roots, and writes an expected string to them.
763 # Doesn't write to a marker file that already exists, because it
764 # might be a sign that the path it received is ridiculously
765 # wrong. Don't want to go overwriting random files now do we?
766 self.installRunPartsScript(distro, "finalize.d", dedent("""\
767 #!/bin/sh -e
768 MARKER_NAME="marker file"
769 for DIRECTORY in $ARCHIVEROOTS
770 do
771 MARKER="$DIRECTORY/$MARKER_NAME"
772 if [ -e "$MARKER" ]
773 then
774 echo "Marker file $MARKER already exists." >&2
775 exit 1
776 fi
777 echo "This is an archive root." >"$MARKER"
778 done
779 """))
780
781 script.runFinalizeParts()
782
783 for archive in [distro.main_archive, distro.getArchive("partner")]:
784 archive_root = getPubConfig(archive).archiveroot
785 self.assertEqual(
786 "This is an archive root.",
787 self.readMarkerFile([archive_root, "marker file"]).rstrip(),
788 "Did not find expected marker for %s."
789 % archive.purpose.title)

Subscribers

People subscribed via source and target branches

to status/vote changes: