Repository: aws/aws-cli

Issue:
S3 command fails silently when copying large file to location without permission
This only happens when copying files larger than a certain size, but not sure what is the threshold. For smaller files, when copying to a directory I don't have permission, I correctly see the permission denied error and the command exits with status 1. But for large files the command just stops, without printing to the output and the exit status is 0.

Steps to reproduce:

```
mkdir access-denied
sudo chown root:wheel access-denied/
aws s3 cp s3://some-bucket/large-file.gz access-denied/
# Result: nothing in the output and exit code is 0
# Expected: exit code of 1 and output showing premission denied
```

Looking at the code, it seems the S3 copy command behaves differently depending on the size of the file being copied. For smaller files it seem to just download the file in a single thread. But for larger files it starts multiple threads, each downloading parts of the file. The single threaded process correctly output the error, but the multi-threaded doesn't.

I suspect this might affect other S3 commands as well.

Pull request to follow.



Discussion:

Resolving patch:
diff --git a/awscli/customizations/s3/s3handler.py b/awscli/customizations/s3/s3handler.py
--- a/awscli/customizations/s3/s3handler.py
+++ b/awscli/customizations/s3/s3handler.py
@@ -260,8 +260,9 @@ def _enqueue_range_download_tasks(self, filename, remove_remote_file=False):
         chunksize = find_chunksize(filename.size, self.chunksize)
         num_downloads = int(filename.size / chunksize)
         context = tasks.MultipartDownloadContext(num_downloads)
-        create_file_task = tasks.CreateLocalFileTask(context=context,
-                                                     filename=filename)
+        create_file_task = tasks.CreateLocalFileTask(
+            context=context, filename=filename,
+            result_queue=self.result_queue)
         self.executor.submit(create_file_task)
         self._do_enqueue_range_download_tasks(
             filename=filename, chunksize=chunksize,
diff --git a/awscli/customizations/s3/tasks.py b/awscli/customizations/s3/tasks.py
--- a/awscli/customizations/s3/tasks.py
+++ b/awscli/customizations/s3/tasks.py
@@ -263,9 +263,10 @@ def __call__(self):
 
 
 class CreateLocalFileTask(OrderableTask):
-    def __init__(self, context, filename):
+    def __init__(self, context, filename, result_queue):
         self._context = context
         self._filename = filename
+        self._result_queue = result_queue
 
     def __call__(self):
         dirname = os.path.dirname(self._filename.dest)
@@ -284,6 +285,11 @@ def __call__(self):
             with open(self._filename.dest, 'wb'):
                 pass
         except Exception as e:
+            message = print_operation(self._filename, failed=True,
+                                      dryrun=False)
+            message += '\n' + str(e)
+            result = {'message': message, 'error': True}
+            self._result_queue.put(PrintTask(**result))
             self._context.cancel()
         else:
             self._context.announce_file_created()


Test patch:
diff --git a/tests/integration/customizations/s3/test_plugin.py b/tests/integration/customizations/s3/test_plugin.py
--- a/tests/integration/customizations/s3/test_plugin.py
+++ b/tests/integration/customizations/s3/test_plugin.py
@@ -811,6 +811,38 @@ def test_is_special_file(self):
                        "socket." % file_path), p.stderr)
 
 
+class TestUnableToWriteToFile(BaseS3CLICommand):
+
+    def extra_setup(self):
+        self.bucket_name = self.create_bucket()
+
+    @skip_if_windows('Write permissions tests only supported on mac/linux')
+    def test_no_write_access_small_file(self):
+        os.chmod(self.files.rootdir, 0o444)
+        self.put_object(self.bucket_name, 'foo.txt',
+                        contents='Hello world')
+        p = aws('s3 cp s3://%s/foo.txt %s' % (
+            self.bucket_name, os.path.join(self.files.rootdir, 'foo.txt')))
+        self.assertEqual(p.rc, 1)
+        self.assertIn('download failed', p.stderr)
+
+    @skip_if_windows('Write permissions tests only supported on mac/linux')
+    def test_no_write_access_large_file(self):
+        # We have to use a file like object because using a string
+        # would result in the header + body sent as a single packet
+        # which effectively disables the expect 100 continue logic.
+        # This will result in a test error because we won't follow
+        # the temporary redirect for the newly created bucket.
+        contents = six.StringIO('a' * 10 * 1024 * 1024)
+        self.put_object(self.bucket_name, 'foo.txt',
+                        contents=contents)
+        os.chmod(self.files.rootdir, 0o444)
+        p = aws('s3 cp s3://%s/foo.txt %s' % (
+            self.bucket_name, os.path.join(self.files.rootdir, 'foo.txt')))
+        self.assertEqual(p.rc, 1)
+        self.assertIn('download failed', p.stderr)
+
+
 @skip_if_windows('Symlink tests only supported on mac/linux')
 class TestSymlinks(BaseS3CLICommand):
     """
diff --git a/tests/unit/customizations/s3/test_tasks.py b/tests/unit/customizations/s3/test_tasks.py
--- a/tests/unit/customizations/s3/test_tasks.py
+++ b/tests/unit/customizations/s3/test_tasks.py
@@ -15,6 +15,10 @@
 import threading
 import mock
 import socket
+import os
+import tempfile
+import shutil
+from six.moves import queue
 
 from botocore.exceptions import IncompleteReadError
 from botocore.vendored.requests.packages.urllib3.exceptions import \
@@ -31,6 +35,7 @@
 from awscli.customizations.s3.tasks import RetriesExeededError
 from awscli.customizations.s3.executor import ShutdownThreadRequest
 from awscli.customizations.s3.utils import StablePriorityQueue
+from awscli.testutils import skip_if_windows
 
 
 class TestMultipartUploadContext(unittest.TestCase):
@@ -326,6 +331,40 @@ def test_print_operation(self):
         self.assertIn(r'e:\foo', message)
 
 
+class TestCreateLocalFileTask(unittest.TestCase):
+    def setUp(self):
+        self.result_queue = queue.Queue()
+        self.tempdir = tempfile.mkdtemp()
+        self.filename = mock.Mock()
+        self.filename.src = 'bucket/key'
+        self.filename.dest = os.path.join(self.tempdir, 'local', 'file')
+        self.filename.operation_name = 'download'
+        self.context = mock.Mock()
+        self.task = CreateLocalFileTask(self.context,
+                                        self.filename,
+                                        self.result_queue)
+
+    def tearDown(self):
+        shutil.rmtree(self.tempdir)
+
+    def test_creates_file_and_announces(self):
+        self.task()
+        self.assertTrue(os.path.isfile(self.filename.dest))
+        self.context.announce_file_created.assert_called_with()
+        self.assertTrue(self.result_queue.empty())
+
+    @skip_if_windows('Write permissions tests only supported on mac/linux')
+    def test_cancel_command_on_exception(self):
+        # Set destination directory to read-only
+        os.chmod(self.tempdir, 0o444)
+        self.task()
+        self.assertFalse(os.path.isfile(self.filename.dest))
+        self.context.cancel.assert_called_with()
+        self.assertFalse(self.result_queue.empty())
+        error_message = self.result_queue.get()
+        self.assertIn("download failed", error_message.message)
+
+
 class TestDownloadPartTask(unittest.TestCase):
     def setUp(self):
         self.result_queue = mock.Mock()
@@ -495,7 +534,7 @@ def setUp(self):
     def create_task(self):
         # We don't actually care about the arguments, we just want to test
         # the ordering of the tasks.
-        return CreateLocalFileTask(None, None)
+        return CreateLocalFileTask(None, None, None)
 
     def complete_task(self):
         return CompleteDownloadTask(None, None, None, None, None)