diff --git a/core/modules/migrate/src/Plugin/migrate/process/Download.php b/core/modules/migrate/src/Plugin/migrate/process/Download.php
new file mode 100644
index 0000000000000000000000000000000000000000..08c9b57b19af5aa29153fb55c4f972b5dd2b895b
--- /dev/null
+++ b/core/modules/migrate/src/Plugin/migrate/process/Download.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\migrate\Plugin\migrate\process;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+use GuzzleHttp\Client;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Downloads a file from a remote location into the local file system.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "download"
+ * )
+ */
+class Download extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The Guzzle HTTP Client service.
+   *
+   * @var \GuzzleHttp\Client
+   */
+  protected $httpClient;
+
+  /**
+   * Constructs a download process plugin.
+   *
+   * @param array $configuration
+   *   The plugin configuration.
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param mixed $plugin_definition
+   *   The plugin definition.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \GuzzleHttp\Client $http_client
+   *   The HTTP client.
+   */
+  public function __construct(array $configuration, $plugin_id, array $plugin_definition, FileSystemInterface $file_system, Client $http_client) {
+    $configuration += [
+      'rename' => FALSE,
+      'guzzle_options' => [],
+    ];
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->fileSystem = $file_system;
+    $this->httpClient = $http_client;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('file_system'),
+      $container->get('http_client')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    // If we're stubbing a file entity, return a uri of NULL so it will get
+    // stubbed by the general process.
+    if ($row->isStub()) {
+      return NULL;
+    }
+    list($source, $destination) = $value;
+
+    // Modify the destination filename if necessary.
+    $replace = !empty($this->configuration['rename']) ?
+      FILE_EXISTS_RENAME :
+      FILE_EXISTS_REPLACE;
+    $final_destination = file_destination($destination, $replace);
+
+    // Try opening the file first, to avoid calling file_prepare_directory()
+    // unnecessarily. We're suppressing fopen() errors because we want to try
+    // to prepare the directory before we give up and fail.
+    $destination_stream = @fopen($final_destination, 'w');
+    if (!$destination_stream) {
+      // If fopen didn't work, make sure there's a writable directory in place.
+      $dir = $this->fileSystem->dirname($final_destination);
+      if (!file_prepare_directory($dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
+        throw new MigrateException("Could not create or write to directory '$dir'");
+      }
+      // Let's try that fopen again.
+      $destination_stream = @fopen($final_destination, 'w');
+      if (!$destination_stream) {
+        throw new MigrateException("Could not write to file '$final_destination'");
+      }
+    }
+
+    // Stream the request body directly to the final destination stream.
+    $this->configuration['guzzle_options']['sink'] = $destination_stream;
+
+    // Make the request. Guzzle throws an exception for anything other than 200.
+    $this->httpClient->get($source, $this->configuration['guzzle_options']);
+
+    return $final_destination;
+  }
+
+}
diff --git a/core/modules/migrate/src/Plugin/migrate/process/FileCopy.php b/core/modules/migrate/src/Plugin/migrate/process/FileCopy.php
index 7c49715d6cf058f5d58e2dca6d2a69462c47ab12..c68fe344eac64b04219607b570acbda00e57bd84 100644
--- a/core/modules/migrate/src/Plugin/migrate/process/FileCopy.php
+++ b/core/modules/migrate/src/Plugin/migrate/process/FileCopy.php
@@ -8,6 +8,7 @@
 use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrateProcessInterface;
 use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\Row;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -35,6 +36,13 @@ class FileCopy extends ProcessPluginBase implements ContainerFactoryPluginInterf
    */
   protected $fileSystem;
 
+  /**
+   * An instance of the download process plugin.
+   *
+   * @var \Drupal\migrate\Plugin\MigrateProcessInterface
+   */
+  protected $downloadPlugin;
+
   /**
    * Constructs a file_copy process plugin.
    *
@@ -48,8 +56,10 @@ class FileCopy extends ProcessPluginBase implements ContainerFactoryPluginInterf
    *   The stream wrapper manager service.
    * @param \Drupal\Core\File\FileSystemInterface $file_system
    *   The file system service.
+   * @param \Drupal\migrate\Plugin\MigrateProcessInterface $download_plugin
+   *   An instance of the download plugin for handling remote URIs.
    */
-  public function __construct(array $configuration, $plugin_id, array $plugin_definition, StreamWrapperManagerInterface $stream_wrappers, FileSystemInterface $file_system) {
+  public function __construct(array $configuration, $plugin_id, array $plugin_definition, StreamWrapperManagerInterface $stream_wrappers, FileSystemInterface $file_system, MigrateProcessInterface $download_plugin) {
     $configuration += array(
       'move' => FALSE,
       'rename' => FALSE,
@@ -58,6 +68,7 @@ public function __construct(array $configuration, $plugin_id, array $plugin_defi
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->streamWrapperManager = $stream_wrappers;
     $this->fileSystem = $file_system;
+    $this->downloadPlugin = $download_plugin;
   }
 
   /**
@@ -69,7 +80,8 @@ public static function create(ContainerInterface $container, array $configuratio
       $plugin_id,
       $plugin_definition,
       $container->get('stream_wrapper_manager'),
-      $container->get('file_system')
+      $container->get('file_system'),
+      $container->get('plugin.manager.migrate.process')->createInstance('download')
     );
   }
 
@@ -84,8 +96,14 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
     }
     list($source, $destination) = $value;
 
+    // If the source path or URI represents a remote resource, delegate to the
+    // download plugin.
+    if (!$this->isLocalUri($source)) {
+      return $this->downloadPlugin->transform($value, $migrate_executable, $row, $destination_property);
+    }
+
     // Ensure the source file exists, if it's a local URI or path.
-    if ($this->isLocalUri($source) && !file_exists($source)) {
+    if (!file_exists($source)) {
       throw new MigrateException("File '$source' does not exist");
     }
 
@@ -128,20 +146,14 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
    *   File destination on success, FALSE on failure.
    */
   protected function writeFile($source, $destination, $replace = FILE_EXISTS_REPLACE) {
-    if ($this->configuration['move']) {
-      return file_unmanaged_move($source, $destination, $replace);
-    }
     // Check if there is a destination available for copying. If there isn't,
     // it already exists at the destination and the replace flag tells us to not
     // replace it. In that case, return the original destination.
     if (!($final_destination = file_destination($destination, $replace))) {
       return $destination;
     }
-    // We can't use file_unmanaged_copy because it will break with remote Urls.
-    if (@copy($source, $final_destination)) {
-      return $final_destination;
-    }
-    return FALSE;
+    $function = 'file_unmanaged_' . ($this->configuration['move'] ? 'move' : 'copy');
+    return $function($source, $destination, $replace);
   }
 
   /**
@@ -187,8 +199,6 @@ protected function getDirectory($uri) {
   /**
    * Determines if the source and destination URIs represent identical paths.
    *
-   * If either URI is a remote stream, will return FALSE.
-   *
    * @param string $source
    *   The source URI.
    * @param string $destination
@@ -199,10 +209,7 @@ protected function getDirectory($uri) {
    *   otherwise FALSE.
    */
   protected function isLocationUnchanged($source, $destination) {
-    if ($this->isLocalUri($source) && $this->isLocalUri($destination)) {
-      return $this->fileSystem->realpath($source) === $this->fileSystem->realpath($destination);
-    }
-    return FALSE;
+    return $this->fileSystem->realpath($source) === $this->fileSystem->realpath($destination);
   }
 
   /**
@@ -219,6 +226,13 @@ protected function isLocationUnchanged($source, $destination) {
    */
   protected function isLocalUri($uri) {
     $scheme = $this->fileSystem->uriScheme($uri);
+
+    // The vfs scheme is vfsStream, which is used in testing. vfsStream is a
+    // simulated file system that exists only in memory, but should be treated
+    // as a local resource.
+    if ($scheme == 'vfs') {
+      $scheme = FALSE;
+    }
     return $scheme === FALSE || $this->streamWrapperManager->getViaScheme($scheme) instanceof LocalStream;
   }
 
diff --git a/core/modules/migrate/tests/src/Kernel/process/CopyFileTest.php b/core/modules/migrate/tests/src/Kernel/process/CopyFileTest.php
index 42c3f04fce470e8affa2f1bc552289be82edda17..427d413bc62898b1c5c9188972bb68b9b8f200a4 100644
--- a/core/modules/migrate/tests/src/Kernel/process/CopyFileTest.php
+++ b/core/modules/migrate/tests/src/Kernel/process/CopyFileTest.php
@@ -18,7 +18,7 @@ class CopyFileTest extends FileTestBase {
   /**
    * {@inheritdoc}
    */
-  public static $modules = ['system'];
+  public static $modules = ['migrate', 'system'];
 
   /**
    * The file system service.
diff --git a/core/modules/migrate/tests/src/Kernel/process/FileCopyTest.php b/core/modules/migrate/tests/src/Kernel/process/FileCopyTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee75e454548fcfb280ea83102beaa8417311f372
--- /dev/null
+++ b/core/modules/migrate/tests/src/Kernel/process/FileCopyTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\Tests\migrate\Kernel\process;
+
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\KernelTests\Core\File\FileTestBase;
+use Drupal\migrate\Plugin\migrate\process\FileCopy;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrateProcessInterface;
+use Drupal\migrate\Row;
+
+/**
+ * Tests the file_copy process plugin.
+ *
+ * @group migrate
+ */
+class FileCopyTest extends FileTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['migrate', 'system'];
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->fileSystem = $this->container->get('file_system');
+    $this->container->get('stream_wrapper_manager')->registerWrapper('temporary', 'Drupal\Core\StreamWrapper\TemporaryStream', StreamWrapperInterface::LOCAL_NORMAL);
+  }
+
+  /**
+   * Test successful imports/copies.
+   */
+  public function testSuccessfulCopies() {
+    $file = $this->createUri(NULL, NULL, 'temporary');
+    $file_absolute = $this->fileSystem->realpath($file);
+    $data_sets = [
+      // Test a local to local copy.
+      [
+        $this->root . '/core/modules/simpletest/files/image-test.jpg',
+        'public://file1.jpg'
+      ],
+      // Test a temporary file using an absolute path.
+      [
+        $file_absolute,
+        'temporary://test.jpg'
+      ],
+      // Test a temporary file using a relative path.
+      [
+        $file_absolute,
+        'temporary://core/modules/simpletest/files/test.jpg'
+      ],
+    ];
+    foreach ($data_sets as $data) {
+      list($source_path, $destination_path) = $data;
+      $actual_destination = $this->doTransform($source_path, $destination_path);
+      $message = sprintf('File %s exists', $destination_path);
+      $this->assertFileExists($destination_path, $message);
+      // Make sure we didn't accidentally do a move.
+      $this->assertFileExists($source_path, $message);
+      $this->assertSame($actual_destination, $destination_path, 'The import returned the copied filename.');
+    }
+  }
+
+  /**
+   * Test successful moves.
+   */
+  public function testSuccessfulMoves() {
+    $file_1 = $this->createUri(NULL, NULL, 'temporary');
+    $file_1_absolute = $this->fileSystem->realpath($file_1);
+    $file_2 = $this->createUri(NULL, NULL, 'temporary');
+    $file_2_absolute = $this->fileSystem->realpath($file_2);
+    $local_file = $this->createUri(NULL, NULL, 'public');
+    $data_sets = [
+      // Test a local to local copy.
+      [
+        $local_file,
+        'public://file1.jpg'
+      ],
+      // Test a temporary file using an absolute path.
+      [
+        $file_1_absolute,
+        'temporary://test.jpg'
+      ],
+      // Test a temporary file using a relative path.
+      [
+        $file_2_absolute,
+        'temporary://core/modules/simpletest/files/test.jpg'
+      ],
+    ];
+    foreach ($data_sets as $data) {
+      list($source_path, $destination_path) = $data;
+      $actual_destination = $this->doTransform($source_path, $destination_path, ['move' => TRUE]);
+      $message = sprintf('File %s exists', $destination_path);
+      $this->assertFileExists($destination_path, $message);
+      $message = sprintf('File %s does not exist', $source_path);
+      $this->assertFileNotExists($source_path, $message);
+      $this->assertSame($actual_destination, $destination_path, 'The importer returned the moved filename.');
+    }
+  }
+
+  /**
+   * Test that non-existent files throw an exception.
+   *
+   * @expectedException \Drupal\migrate\MigrateException
+   *
+   * @expectedExceptionMessage File '/non/existent/file' does not exist
+   */
+  public function testNonExistentSourceFile() {
+    $source = '/non/existent/file';
+    $this->doTransform($source, 'public://wontmatter.jpg');
+  }
+
+  /**
+   * Test the 'rename' overwrite mode.
+   */
+  public function testRenameFile() {
+    $source = $this->createUri(NULL, NULL, 'temporary');
+    $destination = $this->createUri('foo.txt', NULL, 'public');
+    $expected_destination = 'public://foo_0.txt';
+    $actual_destination = $this->doTransform($source, $destination, ['rename' => TRUE]);
+    $this->assertFileExists($expected_destination, 'File was renamed on import');
+    $this->assertSame($actual_destination, $expected_destination, 'The importer returned the renamed filename.');
+  }
+
+  /**
+   * Tests that remote URIs are delegated to the download plugin.
+   */
+  public function testDownloadRemoteUri() {
+    $download_plugin = $this->getMock(MigrateProcessInterface::class);
+    $download_plugin->expects($this->once())->method('transform');
+
+    $plugin = new FileCopy(
+      [],
+      $this->randomMachineName(),
+      [],
+      $this->container->get('stream_wrapper_manager'),
+      $this->container->get('file_system'),
+      $download_plugin
+    );
+
+    $plugin->transform(
+      ['http://drupal.org/favicon.ico', '/destination/path'],
+      $this->getMock(MigrateExecutableInterface::class),
+      new Row([], []),
+      $this->randomMachineName()
+    );
+  }
+
+  /**
+   * Do an import using the destination.
+   *
+   * @param string $source_path
+   *   Source path to copy from.
+   * @param string $destination_path
+   *   The destination path to copy to.
+   * @param array $configuration
+   *   Process plugin configuration settings.
+   *
+   * @return string
+   *   The URI of the copied file.
+   */
+  protected function doTransform($source_path, $destination_path, $configuration = []) {
+    $plugin = FileCopy::create($this->container, $configuration, 'file_copy', []);
+    $executable = $this->prophesize(MigrateExecutableInterface::class)->reveal();
+    $row = new Row([], []);
+
+    return $plugin->transform([$source_path, $destination_path], $executable, $row, 'foobaz');
+  }
+
+}
diff --git a/core/modules/migrate/tests/src/Unit/process/DownloadTest.php b/core/modules/migrate/tests/src/Unit/process/DownloadTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4de42e5b68394042f0c5c3cee2912e6ef814c07
--- /dev/null
+++ b/core/modules/migrate/tests/src/Unit/process/DownloadTest.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Drupal\Tests\migrate\Unit\process;
+
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\KernelTests\Core\File\FileTestBase;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\Plugin\migrate\process\Download;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+use GuzzleHttp\Client;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Tests the download process plugin.
+ *
+ * @group migrate
+ */
+class DownloadTest extends FileTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->container->get('stream_wrapper_manager')->registerWrapper('temporary', 'Drupal\Core\StreamWrapper\TemporaryStream', StreamWrapperInterface::LOCAL_NORMAL);
+  }
+
+  /**
+   * Tests a download that overwrites an existing local file.
+   */
+  public function testOverwritingDownload() {
+    // Create a pre-existing file at the destination, to test overwrite behavior.
+    $destination_uri = $this->createUri('existing_file.txt');
+
+    // Test destructive download.
+    $actual_destination = $this->doTransform($destination_uri);
+    $this->assertSame($destination_uri, $actual_destination, 'Import returned a destination that was not renamed');
+    $this->assertFileNotExists('public://existing_file_0.txt', 'Import did not rename the file');
+  }
+
+  /**
+   * Tests a download that renames the downloaded file if there's a collision.
+   */
+  public function testNonDestructiveDownload() {
+    // Create a pre-existing file at the destination, to test overwrite behavior.
+    $destination_uri = $this->createUri('another_existing_file.txt');
+
+    // Test non-destructive download.
+    $actual_destination = $this->doTransform($destination_uri, ['rename' => TRUE]);
+    $this->assertSame('public://another_existing_file_0.txt', $actual_destination, 'Import returned a renamed destination');
+    $this->assertFileExists($actual_destination, 'Downloaded file was created');
+  }
+
+  /**
+   * Tests that an exception is thrown if the destination URI is not writable.
+   */
+  public function testWriteProectedDestination() {
+    // Create a pre-existing file at the destination, to test overwrite behavior.
+    $destination_uri = $this->createUri('not-writable.txt');
+
+    // Make the destination non-writable.
+    $this->container
+      ->get('file_system')
+      ->chmod($destination_uri, 0444);
+
+    // Pass or fail, we'll need to make the file writable again so the test
+    // can clean up after itself.
+    $fix_permissions = function () use ($destination_uri) {
+      $this->container
+        ->get('file_system')
+        ->chmod($destination_uri, 0755);
+    };
+
+    try {
+      $this->doTransform($destination_uri);
+      $fix_permissions();
+      $this->fail('MigrateException was not thrown for non-writable destination URI.');
+    }
+    catch (MigrateException $e) {
+      $this->assertTrue(TRUE, 'MigrateException was thrown for non-writable destination URI.');
+      $fix_permissions();
+    }
+  }
+
+  /**
+   * Runs an input value through the download plugin.
+   *
+   * @param string $destination_uri
+   *   The destination URI to download to.
+   * @param array $configuration
+   *   (optional) Configuration for the download plugin.
+   *
+   * @return string
+   *   The local URI of the downloaded file.
+   */
+  protected function doTransform($destination_uri, $configuration = []) {
+    // The HTTP client will return a file with contents 'It worked!'
+    $body = fopen('data://text/plain;base64,SXQgd29ya2VkIQ==', 'r');
+
+    // Prepare a mock HTTP client.
+    $this->container->set('http_client', $this->getMock(Client::class));
+    $this->container->get('http_client')
+      ->method('get')
+      ->willReturn(new Response(200, [], $body));
+
+    // Instantiate the plugin statically so it can pull dependencies out of
+    // the container.
+    $plugin = Download::create($this->container, $configuration, 'download', []);
+
+    // Execute the transformation.
+    $executable = $this->getMock(MigrateExecutableInterface::class);
+    $row = new Row([], []);
+
+    // Return the downloaded file's local URI.
+    $value = [
+      'http://drupal.org/favicon.ico',
+      $destination_uri,
+    ];
+    return $plugin->transform($value, $executable, $row, 'foobaz');
+  }
+
+}