launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #20540
[Merge] lp:~cjwatson/launchpad/librarian-check-content-length into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/librarian-check-content-length into lp:launchpad.
Commit message:
Check Content-Length in librarian responses.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/librarian-check-content-length/+merge/296120
Check Content-Length in librarian responses. httplib has a corner case where it doesn't do that for us in exactly the situation that's most common when streaming files from the librarian.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/librarian-check-content-length into lp:launchpad.
=== modified file 'lib/lp/services/daemons/tachandler.py'
--- lib/lp/services/daemons/tachandler.py 2014-04-25 12:31:15 +0000
+++ lib/lp/services/daemons/tachandler.py 2016-05-31 17:02:35 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2014 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Test harness for TAC (Twisted Application Configuration) files."""
@@ -44,7 +44,7 @@
# readyservice.LOG_MAGIC string and return immediately, provoking
# hard-to-diagnose race conditions. Delete the logfile to make sure
# this does not happen.
- remove_if_exists(self.logfile)
+ self.removeLog()
with override_environ(LP_DEBUG_SQL=None):
TacTestFixture.setUp(self,
python_path=sys.executable,
@@ -62,6 +62,9 @@
else:
return False
+ def removeLog(self):
+ remove_if_exists(self.logfile)
+
def truncateLog(self):
"""Truncate the log file.
=== modified file 'lib/lp/services/librarian/client.py'
--- lib/lp/services/librarian/client.py 2015-12-10 00:37:05 +0000
+++ lib/lp/services/librarian/client.py 2016-05-31 17:02:35 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
__metaclass__ = type
@@ -14,6 +14,7 @@
import hashlib
+import httplib
from select import select
import socket
from socket import (
@@ -285,6 +286,18 @@
def __init__(self, file, url):
self.file = file
self.url = url
+ headers = file.info()
+ chunked = (headers.get("Transfer-Encoding") == "chunked")
+ if not chunked and "Content-Length" in headers:
+ try:
+ self.length = int(headers["Content-Length"])
+ except ValueError:
+ self.length = None
+ else:
+ if self.length < 0:
+ self.length = None
+ else:
+ self.length = None
def read(self, chunksize=None):
request = get_current_browser_request()
@@ -292,9 +305,20 @@
action = timeline.start("librarian-read", self.url)
try:
if chunksize is None:
- return self.file.read()
+ s = self.file.read()
else:
- return self.file.read(chunksize)
+ if self.length is not None:
+ chunksize = min(chunksize, self.length)
+ s = self.file.read(chunksize)
+ if self.length is not None:
+ self.length -= len(s)
+ # httplib doesn't quite do all the checking we need: in
+ # particular, it won't fail on a short bounded-size read
+ # from a non-chunked-transfer-coding resource. Check this
+ # manually.
+ if not s and chunksize != 0 and self.length:
+ raise httplib.IncompleteRead(s)
+ return s
finally:
action.finish()
=== added file 'lib/lp/services/librarian/tests/fakeserver.tac'
--- lib/lp/services/librarian/tests/fakeserver.tac 1970-01-01 00:00:00 +0000
+++ lib/lp/services/librarian/tests/fakeserver.tac 2016-05-31 17:02:35 +0000
@@ -0,0 +1,56 @@
+# Copyright 2016 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A fake server that can lie about Content-Length."""
+
+__metaclass__ = type
+
+import os
+
+from twisted.application import (
+ service,
+ strports,
+ )
+from twisted.web import (
+ resource,
+ server,
+ )
+
+from lp.services.daemons import readyservice
+
+
+class FakeResource(resource.Resource):
+
+ isLeaf = True
+
+ def render_GET(self, request):
+ return b"abcdef"
+
+
+class FakeRequest(server.Request):
+ """A Request that can send a Content-Length larger than the content."""
+
+ def __init__(self, *args, **kwargs):
+ server.Request.__init__(self, *args, **kwargs)
+ if "LP_EXTRA_CONTENT_LENGTH" in os.environ:
+ self.extra_content_length = int(
+ os.environ["LP_EXTRA_CONTENT_LENGTH"])
+ else:
+ self.extra_content_length = None
+
+ def setHeader(self, name, value):
+ if (name.lower() == b"content-length" and
+ self.extra_content_length is not None):
+ value = str(int(value) + self.extra_content_length).encode("UTF-8")
+ server.Request.setHeader(self, name, value)
+
+
+application = service.Application("FakeServer")
+services = service.IServiceCollection(application)
+
+readyservice.ReadyService().setServiceParent(services)
+
+site = server.Site(FakeResource())
+site.requestFactory = FakeRequest
+site.displayTracebacks = False
+strports.service("tcp:0", site).setServiceParent(services)
=== modified file 'lib/lp/services/librarian/tests/test_client.py'
--- lib/lp/services/librarian/tests/test_client.py 2014-01-30 15:04:06 +0000
+++ lib/lp/services/librarian/tests/test_client.py 2016-05-31 17:02:35 +0000
@@ -1,30 +1,41 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2016 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
from cStringIO import StringIO
import hashlib
import httplib
+import os
+import re
import textwrap
import unittest
from urllib2 import (
HTTPError,
URLError,
+ urlopen,
)
+from fixtures import (
+ EnvironmentVariable,
+ TempDir,
+ )
import transaction
from lp.services.config import config
+from lp.services.daemons.tachandler import TacTestSetup
from lp.services.database.interfaces import ISlaveStore
from lp.services.database.policy import SlaveDatabasePolicy
from lp.services.database.sqlbase import block_implicit_flushes
from lp.services.librarian import client as client_module
from lp.services.librarian.client import (
+ _File,
LibrarianClient,
LibrarianServerError,
RestrictedLibrarianClient,
)
from lp.services.librarian.interfaces.client import UploadFailed
from lp.services.librarian.model import LibraryFileAlias
+from lp.services.propertycache import cachedproperty
+from lp.testing import TestCase
from lp.testing.layers import (
DatabaseLayer,
FunctionalLayer,
@@ -79,6 +90,80 @@
return mock_file
+class FakeServerTestSetup(TacTestSetup):
+
+ def setUp(self):
+ self.port = None
+ super(FakeServerTestSetup, self).setUp()
+
+ def setUpRoot(self):
+ pass
+
+ @cachedproperty
+ def root(self):
+ return self.useFixture(TempDir()).path
+
+ @property
+ def tacfile(self):
+ return os.path.join(os.path.dirname(__file__), "fakeserver.tac")
+
+ @property
+ def pidfile(self):
+ return os.path.join(self.root, "fakeserver.pid")
+
+ @property
+ def logfile(self):
+ return os.path.join(self.root, "fakeserver.log")
+
+ def removeLog(self):
+ pass
+
+ def _hasDaemonStarted(self):
+ if super(FakeServerTestSetup, self)._hasDaemonStarted():
+ with open(self.logfile) as logfile:
+ self.port = int(re.search(
+ r"Site starting on (\d+)", logfile.read()).group(1))
+ return True
+ else:
+ return False
+
+
+class LibrarianFileWrapperTestCase(TestCase):
+ """Test behaviour of the _File wrapper used by the librarian client."""
+
+ def makeFile(self, extra_content_length=None):
+ extra_content_length_str = (
+ str(extra_content_length) if extra_content_length is not None
+ else None)
+ self.useFixture(EnvironmentVariable(
+ "LP_EXTRA_CONTENT_LENGTH", extra_content_length_str))
+ port = self.useFixture(FakeServerTestSetup()).port
+ url = "http://localhost:%d/" % port
+ return _File(urlopen(url), url)
+
+ def test_unbounded_read_correct_length(self):
+ file = self.makeFile()
+ self.assertEqual(b"abcdef", file.read())
+ self.assertEqual(b"", file.read())
+
+ def test_unbounded_read_incorrect_length(self):
+ file = self.makeFile(extra_content_length=1)
+ self.assertEqual(b"abcdef", file.read())
+ self.assertRaises(httplib.IncompleteRead, file.read)
+
+ def test_bounded_read_correct_length(self):
+ file = self.makeFile()
+ self.assertEqual(b"abcd", file.read(chunksize=4))
+ self.assertEqual(b"ef", file.read(chunksize=4))
+ self.assertEqual(b"", file.read(chunksize=4))
+
+ def test_bounded_read_incorrect_length(self):
+ file = self.makeFile(extra_content_length=1)
+ self.assertEqual(b"abcd", file.read(chunksize=4))
+ self.assertEqual(b"ef", file.read(chunksize=4))
+ self.assertRaises(httplib.IncompleteRead, file.read, chunksize=4)
+
+
class LibrarianClientTestCase(unittest.TestCase):
layer = LaunchpadFunctionalLayer
Follow ups