From ec6dfb8297f7903911f4ecfdd2278e720709af09 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Thu, 23 Sep 2021 14:20:34 -0400 Subject: [PATCH 01/12] Re-enable test. --- src/allmydata/test/test_istorageserver.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/allmydata/test/test_istorageserver.py b/src/allmydata/test/test_istorageserver.py index 328000489..a63189204 100644 --- a/src/allmydata/test/test_istorageserver.py +++ b/src/allmydata/test/test_istorageserver.py @@ -248,12 +248,11 @@ class IStorageServerImmutableAPIsTestsMixin(object): (yield buckets[2].callRemote("read", 0, 1024)), b"3" * 512 + b"4" * 512 ) - @skipIf(True, "https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3801") def test_overlapping_writes(self): """ - The policy for overlapping writes is TBD: - https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3801 + Overlapping writes in immutable uploads fail with ``OverlappingWriteError``. """ + 1/0 class _FoolscapMixin(SystemTestMixin): From 1ff4e61e417a5f73f89ed46148dd3e8808a471ed Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 10:33:26 -0400 Subject: [PATCH 02/12] Low-level tests for conflicting and non-conflicting writes. --- src/allmydata/interfaces.py | 8 +++ src/allmydata/storage/common.py | 5 +- src/allmydata/test/test_storage.py | 96 +++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 1c64bce8a..8dffa9e7e 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -53,6 +53,14 @@ LeaseRenewSecret = Hash # used to protect lease renewal requests LeaseCancelSecret = Hash # was used to protect lease cancellation requests +class DataTooLargeError(Exception): + """The write went past the expected size of the bucket.""" + + +class ConflictingWriteError(Exception): + """Two writes happened to same immutable with different data.""" + + class RIBucketWriter(RemoteInterface): """ Objects of this kind live on the server side. """ def write(offset=Offset, data=ShareData): diff --git a/src/allmydata/storage/common.py b/src/allmydata/storage/common.py index cb6116e5b..d72bb3fbc 100644 --- a/src/allmydata/storage/common.py +++ b/src/allmydata/storage/common.py @@ -13,8 +13,9 @@ if PY2: import os.path from allmydata.util import base32 -class DataTooLargeError(Exception): - pass +# Backwards compatibility. +from allmydata.interfaces import DataTooLargeError + class UnknownMutableContainerVersionError(Exception): pass class UnknownImmutableContainerVersionError(Exception): diff --git a/src/allmydata/test/test_storage.py b/src/allmydata/test/test_storage.py index bd0ab80f3..61494eebd 100644 --- a/src/allmydata/test/test_storage.py +++ b/src/allmydata/test/test_storage.py @@ -8,7 +8,7 @@ from __future__ import division from __future__ import print_function from __future__ import unicode_literals -from future.utils import native_str, PY2, bytes_to_native_str +from future.utils import native_str, PY2, bytes_to_native_str, bchr if PY2: from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, str, max, min # noqa: F401 from six import ensure_str @@ -20,12 +20,15 @@ import stat import struct import shutil import gc +from uuid import uuid4 from twisted.trial import unittest from twisted.internet import defer from twisted.internet.task import Clock +from hypothesis import given, strategies + import itertools from allmydata import interfaces from allmydata.util import fileutil, hashutil, base32 @@ -33,7 +36,7 @@ from allmydata.storage.server import StorageServer, DEFAULT_RENEWAL_TIME from allmydata.storage.shares import get_share_file from allmydata.storage.mutable import MutableShareFile from allmydata.storage.immutable import BucketWriter, BucketReader, ShareFile -from allmydata.storage.common import DataTooLargeError, storage_index_to_dir, \ +from allmydata.storage.common import storage_index_to_dir, \ UnknownMutableContainerVersionError, UnknownImmutableContainerVersionError, \ si_b2a, si_a2b from allmydata.storage.lease import LeaseInfo @@ -47,7 +50,9 @@ from allmydata.mutable.layout import MDMFSlotWriteProxy, MDMFSlotReadProxy, \ SIGNATURE_SIZE, \ VERIFICATION_KEY_SIZE, \ SHARE_HASH_CHAIN_SIZE -from allmydata.interfaces import BadWriteEnablerError +from allmydata.interfaces import ( + BadWriteEnablerError, DataTooLargeError, ConflictingWriteError, +) from allmydata.test.no_network import NoNetworkServer from allmydata.storage_client import ( _StorageServer, @@ -147,6 +152,91 @@ class Bucket(unittest.TestCase): self.failUnlessEqual(br.remote_read(25, 25), b"b"*25) self.failUnlessEqual(br.remote_read(50, 7), b"c"*7) + def test_write_past_size_errors(self): + """Writing beyond the size of the bucket throws an exception.""" + for (i, (offset, length)) in enumerate([(0, 201), (10, 191), (202, 34)]): + incoming, final = self.make_workdir( + "test_write_past_size_errors-{}".format(i) + ) + bw = BucketWriter(self, incoming, final, 200, self.make_lease(), + FakeCanary()) + with self.assertRaises(DataTooLargeError): + bw.remote_write(offset, b"a" * length) + + @given( + maybe_overlapping_offset=strategies.integers(min_value=0, max_value=98), + maybe_overlapping_length=strategies.integers(min_value=1, max_value=100), + ) + def test_overlapping_writes_ok_if_matching( + self, maybe_overlapping_offset, maybe_overlapping_length + ): + """ + Writes that overlap with previous writes are OK when the content is the + same. + """ + length = 100 + expected_data = b"".join(bchr(i) for i in range(100)) + incoming, final = self.make_workdir("overlapping_writes_{}".format(uuid4())) + bw = BucketWriter( + self, incoming, final, length, self.make_lease(), + FakeCanary() + ) + # Three writes: 10-19, 30-39, 50-59. This allows for a bunch of holes. + bw.remote_write(10, expected_data[10:20]) + bw.remote_write(30, expected_data[30:40]) + bw.remote_write(50, expected_data[50:60]) + # Then, an overlapping write but with matching data: + bw.remote_write( + maybe_overlapping_offset, + expected_data[ + maybe_overlapping_offset:maybe_overlapping_offset + maybe_overlapping_length + ] + ) + # Now fill in the holes: + bw.remote_write(0, expected_data[0:10]) + bw.remote_write(20, expected_data[20:30]) + bw.remote_write(40, expected_data[40:50]) + bw.remote_write(60, expected_data[60:]) + bw.remote_close() + + br = BucketReader(self, bw.finalhome) + self.assertEqual(br.remote_read(0, length), expected_data) + + + @given( + maybe_overlapping_offset=strategies.integers(min_value=0, max_value=98), + maybe_overlapping_length=strategies.integers(min_value=1, max_value=100), + ) + def test_overlapping_writes_not_ok_if_different( + self, maybe_overlapping_offset, maybe_overlapping_length + ): + """ + Writes that overlap with previous writes fail with an exception if the + contents don't match. + """ + length = 100 + incoming, final = self.make_workdir("overlapping_writes_{}".format(uuid4())) + bw = BucketWriter( + self, incoming, final, length, self.make_lease(), + FakeCanary() + ) + # Three writes: 10-19, 30-39, 50-59. This allows for a bunch of holes. + bw.remote_write(10, b"1" * 10) + bw.remote_write(30, b"1" * 10) + bw.remote_write(50, b"1" * 10) + # Then, write something that might overlap with some of them, but + # conflicts. Then fill in holes left by first three writes. Conflict is + # inevitable. + with self.assertRaises(ConflictingWriteError): + bw.remote_write( + maybe_overlapping_offset, + b'X' * min(maybe_overlapping_length, length - maybe_overlapping_offset), + ) + bw.remote_write(0, b"1" * 10) + bw.remote_write(20, b"1" * 10) + bw.remote_write(40, b"1" * 10) + bw.remote_write(60, b"1" * 40) + def test_read_past_end_of_share_data(self): # test vector for immutable files (hard-coded contents of an immutable share # file): From cae989e8dec1ba76db46d2a4745aa3c3e65a7ee5 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 11:54:03 -0400 Subject: [PATCH 03/12] News file. --- newsfragments/3801.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3801.bugfix diff --git a/newsfragments/3801.bugfix b/newsfragments/3801.bugfix new file mode 100644 index 000000000..504b3999d --- /dev/null +++ b/newsfragments/3801.bugfix @@ -0,0 +1 @@ +When uploading an immutable, overlapping writes that include conflicting data are rejected. In practice, this likely didn't happen in real-world usage. \ No newline at end of file From 6ef3811112ac5323af70aa8b3741c0075817f9fe Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 11:54:08 -0400 Subject: [PATCH 04/12] Prevent conflicting overlapping writes. --- nix/tahoe-lafs.nix | 2 +- setup.py | 3 +++ src/allmydata/storage/immutable.py | 24 +++++++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/nix/tahoe-lafs.nix b/nix/tahoe-lafs.nix index 35b29f1cc..e08a2ab46 100644 --- a/nix/tahoe-lafs.nix +++ b/nix/tahoe-lafs.nix @@ -97,7 +97,7 @@ EOF setuptoolsTrial pyasn1 zope_interface service-identity pyyaml magic-wormhole treq eliot autobahn cryptography netifaces setuptools - future pyutil distro configparser + future pyutil distro configparser collections-extended ]; checkInputs = with python.pkgs; [ diff --git a/setup.py b/setup.py index e1d711ccf..8241898fe 100644 --- a/setup.py +++ b/setup.py @@ -137,6 +137,9 @@ install_requires = [ # Backported configparser for Python 2: "configparser ; python_version < '3.0'", + + # For the RangeMap datastructure. + "collections-extended", ] setup_requires = [ diff --git a/src/allmydata/storage/immutable.py b/src/allmydata/storage/immutable.py index 4b60d79f1..9e3a9622a 100644 --- a/src/allmydata/storage/immutable.py +++ b/src/allmydata/storage/immutable.py @@ -13,16 +13,20 @@ if PY2: import os, stat, struct, time +from collections_extended import RangeMap + from foolscap.api import Referenceable from zope.interface import implementer -from allmydata.interfaces import RIBucketWriter, RIBucketReader +from allmydata.interfaces import ( + RIBucketWriter, RIBucketReader, ConflictingWriteError, + DataTooLargeError, +) from allmydata.util import base32, fileutil, log from allmydata.util.assertutil import precondition from allmydata.util.hashutil import timing_safe_compare from allmydata.storage.lease import LeaseInfo -from allmydata.storage.common import UnknownImmutableContainerVersionError, \ - DataTooLargeError +from allmydata.storage.common import UnknownImmutableContainerVersionError # each share file (in storage/shares/$SI/$SHNUM) contains lease information # and share data. The share data is accessed by RIBucketWriter.write and @@ -217,6 +221,7 @@ class BucketWriter(Referenceable): # type: ignore # warner/foolscap#78 # also, add our lease to the file now, so that other ones can be # added by simultaneous uploaders self._sharefile.add_lease(lease_info) + self._already_written = RangeMap() def allocated_size(self): return self._max_size @@ -226,7 +231,20 @@ class BucketWriter(Referenceable): # type: ignore # warner/foolscap#78 precondition(not self.closed) if self.throw_out_all_data: return + + # Make sure we're not conflicting with existing data: + end = offset + len(data) + for (chunk_start, chunk_stop, _) in self._already_written.ranges(offset, end): + chunk_len = chunk_stop - chunk_start + actual_chunk = self._sharefile.read_share_data(chunk_start, chunk_len) + writing_chunk = data[chunk_start - offset:chunk_stop - offset] + if actual_chunk != writing_chunk: + raise ConflictingWriteError( + "Chunk {}-{} doesn't match already written data.".format(chunk_start, chunk_stop) + ) self._sharefile.write_share_data(offset, data) + + self._already_written.set(True, offset, end) self.ss.add_latency("write", time.time() - start) self.ss.count("write") From c1f8e9f8c763011cd73f1bb7c499fffe1ccf7171 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 12:02:30 -0400 Subject: [PATCH 05/12] IStorageServer test for overlapping writes. --- src/allmydata/test/test_istorageserver.py | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/allmydata/test/test_istorageserver.py b/src/allmydata/test/test_istorageserver.py index a63189204..ef93dadf5 100644 --- a/src/allmydata/test/test_istorageserver.py +++ b/src/allmydata/test/test_istorageserver.py @@ -24,7 +24,7 @@ from testtools import skipIf from twisted.internet.defer import inlineCallbacks -from foolscap.api import Referenceable +from foolscap.api import Referenceable, RemoteException from allmydata.interfaces import IStorageServer from .common_system import SystemTestMixin @@ -248,11 +248,31 @@ class IStorageServerImmutableAPIsTestsMixin(object): (yield buckets[2].callRemote("read", 0, 1024)), b"3" * 512 + b"4" * 512 ) + @inlineCallbacks def test_overlapping_writes(self): """ - Overlapping writes in immutable uploads fail with ``OverlappingWriteError``. + Overlapping, non-identical writes in immutable uploads fail. """ - 1/0 + storage_index, renew_secret, cancel_secret = ( + new_storage_index(), + new_secret(), + new_secret(), + ) + (_, allocated) = yield self.storage_server.allocate_buckets( + storage_index, + renew_secret, + cancel_secret, + sharenums={0}, + allocated_size=30, + canary=Referenceable(), + ) + + yield allocated[0].callRemote("write", 0, b"1" * 10) + # Overlapping write that matches: + yield allocated[0].callRemote("write", 5, b"1" * 20) + # Overlapping write that doesn't match: + with self.assertRaises(RemoteException): + yield allocated[0].callRemote("write", 20, b"2" * 10) class _FoolscapMixin(SystemTestMixin): From 0b1082fc04ffa56a808bddb7fa519665a6135e66 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 12:04:12 -0400 Subject: [PATCH 06/12] Fix lint. --- src/allmydata/storage/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/storage/common.py b/src/allmydata/storage/common.py index d72bb3fbc..e5563647f 100644 --- a/src/allmydata/storage/common.py +++ b/src/allmydata/storage/common.py @@ -14,7 +14,7 @@ import os.path from allmydata.util import base32 # Backwards compatibility. -from allmydata.interfaces import DataTooLargeError +from allmydata.interfaces import DataTooLargeError # noqa: F401 class UnknownMutableContainerVersionError(Exception): pass From 96acb1419977bd64898beb08e93a3c6e58505e8f Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Fri, 24 Sep 2021 13:48:07 -0400 Subject: [PATCH 07/12] Document impact of semantic changes on HTTP protocol. --- docs/proposed/http-storage-node-protocol.rst | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/proposed/http-storage-node-protocol.rst b/docs/proposed/http-storage-node-protocol.rst index a84d62176..bf01bcf12 100644 --- a/docs/proposed/http-storage-node-protocol.rst +++ b/docs/proposed/http-storage-node-protocol.rst @@ -497,22 +497,26 @@ If any one of these requests fails then at most 128KiB of upload work needs to b The server must recognize when all of the data has been received and mark the share as complete (which it can do because it was informed of the size when the storage index was initialized). -Clients should upload chunks in re-assembly order. * When a chunk that does not complete the share is successfully uploaded the response is ``OK``. * When the chunk that completes the share is successfully uploaded the response is ``CREATED``. -* If the *Content-Range* for a request covers part of the share that has already been uploaded the response is ``CONFLICT``. - The response body indicates the range of share data that has yet to be uploaded. - That is:: +* If the *Content-Range* for a request covers part of the share that has already, + and the data does not match already written data, + the response is ``CONFLICT``. + At this point the only thing to do is abort the upload and start from scratch (see below). - { "required": - [ { "begin": - , "end": - } - , - ... - ] - } +``PUT /v1/immutable/:storage_index/:share_number/abort`` +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +This cancels an *in-progress* upload. + +The response code: + +* When the upload is still in progress and therefore the abort has succeeded, + the response is ``OK``. + Future uploads can start from scratch with no pre-existing upload state stored on the server. +* If the uploaded has already finished, the response is 405 (Method Not Allowed) + and no change is made. ``POST /v1/immutable/:storage_index/:share_number/corrupt`` From 38e449aceb611661e899b4dec0babf166ddd5f09 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 27 Sep 2021 16:44:43 -0400 Subject: [PATCH 08/12] Add collections-extended. --- nix/collections-extended.nix | 19 +++++++++++++++++++ nix/overlays.nix | 3 +++ 2 files changed, 22 insertions(+) create mode 100644 nix/collections-extended.nix diff --git a/nix/collections-extended.nix b/nix/collections-extended.nix new file mode 100644 index 000000000..3f1ad165a --- /dev/null +++ b/nix/collections-extended.nix @@ -0,0 +1,19 @@ +{ lib, buildPythonPackage, fetchPypi }: +buildPythonPackage rec { + pname = "collections-extended"; + version = "1.0.3"; + + src = fetchPypi { + inherit pname version; + sha256 = "0lb69x23asd68n0dgw6lzxfclavrp2764xsnh45jm97njdplznkw"; + }; + + # Tests aren't in tarball, for 1.0.3 at least. + doCheck = false; + + meta = with lib; { + homepage = https://github.com/mlenzen/collections-extended; + description = "Extra Python Collections - bags (multisets), setlists (unique list / indexed set), RangeMap and IndexedDict"; + license = licenses.asl20; + }; +} diff --git a/nix/overlays.nix b/nix/overlays.nix index 2bf58575e..aae808131 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -18,6 +18,9 @@ self: super: { # Need a newer version of Twisted, too. twisted = python-super.callPackage ./twisted.nix { }; + + # collections-extended is not part of nixpkgs at this time. + collections-extended = python-super.callPackage ./collections-extended.nix }; }; } From 4e6438352ad95fe332bce67f817aa49d91a20001 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 27 Sep 2021 16:45:01 -0400 Subject: [PATCH 09/12] Extend to end. --- docs/proposed/http-storage-node-protocol.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposed/http-storage-node-protocol.rst b/docs/proposed/http-storage-node-protocol.rst index bf01bcf12..d91687960 100644 --- a/docs/proposed/http-storage-node-protocol.rst +++ b/docs/proposed/http-storage-node-protocol.rst @@ -506,7 +506,7 @@ The server must recognize when all of the data has been received and mark the sh At this point the only thing to do is abort the upload and start from scratch (see below). ``PUT /v1/immutable/:storage_index/:share_number/abort`` -!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! This cancels an *in-progress* upload. From 60cb3c08836603016f4fb9e59e075cb34a92f1cb Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 27 Sep 2021 16:52:25 -0400 Subject: [PATCH 10/12] Restore range result. --- docs/proposed/http-storage-node-protocol.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/proposed/http-storage-node-protocol.rst b/docs/proposed/http-storage-node-protocol.rst index d91687960..2984b5c6d 100644 --- a/docs/proposed/http-storage-node-protocol.rst +++ b/docs/proposed/http-storage-node-protocol.rst @@ -499,6 +499,18 @@ The server must recognize when all of the data has been received and mark the sh (which it can do because it was informed of the size when the storage index was initialized). * When a chunk that does not complete the share is successfully uploaded the response is ``OK``. + The response body indicates the range of share data that has yet to be uploaded. + That is:: + + { "required": + [ { "begin": + , "end": + } + , + ... + ] + } + * When the chunk that completes the share is successfully uploaded the response is ``CREATED``. * If the *Content-Range* for a request covers part of the share that has already, and the data does not match already written data, From de1a7d7fcee4a400c791f28f1afb32493bb3e43e Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 27 Sep 2021 16:56:24 -0400 Subject: [PATCH 11/12] A more explicit test for successful overlapping. --- src/allmydata/test/test_istorageserver.py | 39 ++++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/allmydata/test/test_istorageserver.py b/src/allmydata/test/test_istorageserver.py index ef93dadf5..9e9a41d35 100644 --- a/src/allmydata/test/test_istorageserver.py +++ b/src/allmydata/test/test_istorageserver.py @@ -249,9 +249,10 @@ class IStorageServerImmutableAPIsTestsMixin(object): ) @inlineCallbacks - def test_overlapping_writes(self): + def test_non_matching_overlapping_writes(self): """ - Overlapping, non-identical writes in immutable uploads fail. + When doing overlapping writes in immutable uploads, non-matching writes + fail. """ storage_index, renew_secret, cancel_secret = ( new_storage_index(), @@ -267,13 +268,41 @@ class IStorageServerImmutableAPIsTestsMixin(object): canary=Referenceable(), ) - yield allocated[0].callRemote("write", 0, b"1" * 10) - # Overlapping write that matches: - yield allocated[0].callRemote("write", 5, b"1" * 20) + yield allocated[0].callRemote("write", 0, b"1" * 25) # Overlapping write that doesn't match: with self.assertRaises(RemoteException): yield allocated[0].callRemote("write", 20, b"2" * 10) + @inlineCallbacks + def test_matching_overlapping_writes(self): + """ + When doing overlapping writes in immutable uploads, matching writes + succeed. + """ + storage_index, renew_secret, cancel_secret = ( + new_storage_index(), + new_secret(), + new_secret(), + ) + (_, allocated) = yield self.storage_server.allocate_buckets( + storage_index, + renew_secret, + cancel_secret, + sharenums={0}, + allocated_size=25, + canary=Referenceable(), + ) + + yield allocated[0].callRemote("write", 0, b"1" * 10) + # Overlapping write that matches: + yield allocated[0].callRemote("write", 5, b"1" * 20) + yield allocated[0].callRemote("close") + + buckets = yield self.storage_server.get_buckets(storage_index) + self.assertEqual(set(buckets.keys()), {0}) + + self.assertEqual((yield buckets[0].callRemote("read", 0, 25)), b"1" * 25) + class _FoolscapMixin(SystemTestMixin): """Run tests on Foolscap version of ``IStorageServer.""" From f66f3e64ada92a37c15d7d2e41c7a37cc78c2c41 Mon Sep 17 00:00:00 2001 From: Itamar Turner-Trauring Date: Mon, 27 Sep 2021 16:58:18 -0400 Subject: [PATCH 12/12] Fix syntax. --- nix/overlays.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/overlays.nix b/nix/overlays.nix index aae808131..14a47ca5a 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -20,7 +20,7 @@ self: super: { twisted = python-super.callPackage ./twisted.nix { }; # collections-extended is not part of nixpkgs at this time. - collections-extended = python-super.callPackage ./collections-extended.nix + collections-extended = python-super.callPackage ./collections-extended.nix { }; }; }; }