diff --git a/meson.build b/meson.build
index 89efe5df86e2375f527a38b447d09c2b05c7bafa..74f6430d2a1c8a6122fcfa34d5db4a3f12bf9f41 100644
--- a/meson.build
+++ b/meson.build
@@ -136,6 +136,7 @@ scripts = [
 ]
 
 tests = [
+  'cheap-copy.py',
   'mypy.sh',
   'pycodestyle.sh',
   'pyflakes.sh',
@@ -149,10 +150,16 @@ test_env.set('G_TEST_BUILDDIR', meson.current_build_dir())
 test_env.set('PRESSURE_VESSEL_UNINSTALLED', 'yes')
 
 foreach test_name : tests
+  test_args = ['-v', files('tests/' + test_name)]
+
+  if test_name.endswith('.py')
+    test_args += ['-e', python.path()]
+  endif
+
   if prove.found()
     test(
       test_name, prove,
-      args : ['-v', files('tests/' + test_name)],
+      args : test_args,
       env : test_env,
     )
   endif
@@ -276,6 +283,28 @@ executable(
   install_dir : get_option('bindir'),
 )
 
+executable(
+  'test-cheap-copy',
+  sources : [
+    'src/flatpak-utils-base.c',
+    'src/flatpak-utils-base-private.h',
+    'src/glib-backports.c',
+    'src/glib-backports.h',
+    'src/utils.c',
+    'src/utils.h',
+    'tests/cheap-copy.c',
+  ],
+  c_args : [
+    '-Wno-unused-local-typedefs',
+  ],
+  dependencies : [
+    dependency('gio-unix-2.0', required : true),
+    subproject('libglnx').get_variable('libglnx_dep'),
+  ],
+  include_directories : project_include_dirs,
+  install : false,
+)
+
 if get_option('man')
   pandoc = find_program('pandoc', required : true)
 
diff --git a/src/utils.c b/src/utils.c
index d51faa3b08935f44d7896f1b25636f56e2c9961e..2a144f3a9cfe5a80d3eea972b2e36b3c812c1d5f 100644
--- a/src/utils.c
+++ b/src/utils.c
@@ -22,6 +22,8 @@
 
 #include "utils.h"
 
+#include <ftw.h>
+
 #include <glib.h>
 #include <glib/gstdio.h>
 #include <gio/gio.h>
@@ -250,3 +252,124 @@ pv_hash_table_get_arbitrary_key (GHashTable *table)
   else
     return NULL;
 }
+
+/* nftw() doesn't have a user_data argument so we need to use a global
+ * variable :-( */
+static struct
+{
+  gchar *source_root;
+  gchar *dest_root;
+  GError *error;
+} nftw_data;
+
+static int
+copy_tree_helper (const char *fpath,
+                  const struct stat *sb,
+                  int typeflag,
+                  struct FTW *ftwbuf)
+{
+  size_t len;
+  const char *suffix;
+  g_autofree gchar *dest = NULL;
+  g_autofree gchar *target = NULL;
+  GError **error = &nftw_data.error;
+
+  g_return_val_if_fail (g_str_has_prefix (fpath, nftw_data.source_root), 1);
+
+  if (strcmp (fpath, nftw_data.source_root) == 0)
+    {
+      if (typeflag != FTW_D)
+        {
+          glnx_throw (error, "\"%s\" is not a directory", fpath);
+          return 1;
+        }
+
+      if (!glnx_shutil_mkdir_p_at (-1, nftw_data.dest_root, 0700, NULL,
+                                   error))
+        return 1;
+
+      return 0;
+    }
+
+  len = strlen (nftw_data.source_root);
+  g_return_val_if_fail (fpath[len] == '/', 1);
+  suffix = &fpath[len + 1];
+  dest = g_build_filename (nftw_data.dest_root, suffix, NULL);
+
+  switch (typeflag)
+    {
+      case FTW_D:
+        /* For now we assume the permissions are not significant */
+        if (!glnx_shutil_mkdir_p_at (-1, dest, 0755, NULL, error))
+          return 1;
+        break;
+
+      case FTW_SL:
+        target = glnx_readlinkat_malloc (-1, fpath, NULL, error);
+
+        if (target == NULL)
+          return 1;
+
+        if (symlink (target, dest) != 0)
+          {
+            glnx_throw_errno_prefix (error,
+                                     "Unable to create symlink at \"%s\"",
+                                     dest);
+            return 1;
+          }
+        break;
+
+      case FTW_F:
+        /* TODO: If creating a hard link doesn't work, fall back to
+         * copying */
+        if (link (fpath, dest) != 0)
+          {
+            glnx_throw_errno_prefix (error,
+                                     "Unable to create hard link from \"%s\" to \"%s\"",
+                                     fpath, dest);
+            return 1;
+          }
+        break;
+
+      default:
+        glnx_throw (&nftw_data.error,
+                    "Don't know how to handle ftw type flag %d at %s",
+                    typeflag, fpath);
+        return 1;
+    }
+
+  return 0;
+}
+
+gboolean
+pv_cheap_tree_copy (const char *source_root,
+                    const char *dest_root,
+                    GError **error)
+{
+  int res;
+
+  /* Can't run concurrently */
+  g_return_val_if_fail (nftw_data.source_root == NULL, FALSE);
+
+  nftw_data.source_root = flatpak_canonicalize_filename (source_root);
+  nftw_data.dest_root = flatpak_canonicalize_filename (dest_root);
+  nftw_data.error = NULL;
+
+  res = nftw (nftw_data.source_root, copy_tree_helper, 100, FTW_PHYS);
+
+  if (res == -1)
+    {
+      g_assert (nftw_data.error == NULL);
+      glnx_throw_errno_prefix (error, "Unable to copy \"%s\" to \"%s\"",
+                               source_root, dest_root);
+    }
+  else if (res != 0)
+    {
+      g_propagate_error (error, g_steal_pointer (&nftw_data.error));
+    }
+
+  g_clear_pointer (&nftw_data.source_root, g_free);
+  g_clear_pointer (&nftw_data.dest_root, g_free);
+  g_assert (nftw_data.error == NULL);
+  return (res == 0);
+}
diff --git a/src/utils.h b/src/utils.h
index 31014575b470919ac0ee1fc14a22ef32c11e63f5..11172fd542cb9797b909b69f221b4a7fc32e828f 100644
--- a/src/utils.h
+++ b/src/utils.h
@@ -42,3 +42,7 @@ gchar *pv_capture_output (const char * const * argv,
                           GError **error);
 
 gpointer pv_hash_table_get_arbitrary_key (GHashTable *table);
+
+gboolean pv_cheap_tree_copy (const char *source_root,
+                             const char *dest_root,
+                             GError **error);
diff --git a/tests/cheap-copy.c b/tests/cheap-copy.c
new file mode 100644
index 0000000000000000000000000000000000000000..9a5bf686c8a1183b48a58cbd2ad761c3942bc73d
--- /dev/null
+++ b/tests/cheap-copy.c
@@ -0,0 +1,78 @@
+/*
+ * Copyright © 2020 Collabora Ltd.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "config.h"
+#include "subprojects/libglnx/config.h"
+
+#include <locale.h>
+#include <sysexits.h>
+
+#include "libglnx/libglnx.h"
+
+#include "glib-backports.h"
+#include "utils.h"
+
+static GOptionEntry options[] =
+{
+  { NULL }
+};
+
+int
+main (int argc,
+      char *argv[])
+{
+  g_autoptr(GOptionContext) context = NULL;
+  g_autoptr(GError) local_error = NULL;
+  GError **error = &local_error;
+  int ret = EX_USAGE;
+
+  setlocale (LC_ALL, "");
+  pv_avoid_gvfs ();
+
+  context = g_option_context_new ("SOURCE DEST");
+  g_option_context_add_main_entries (context, options, NULL);
+
+  if (!g_option_context_parse (context, &argc, &argv, error))
+    goto out;
+
+  if (argc >= 2 && strcmp (argv[1], "--") == 0)
+    {
+      argv++;
+      argc--;
+    }
+
+  if (argc != 3)
+    {
+      g_printerr ("Usage: %s SOURCE DEST\n", g_get_prgname ());
+      goto out;
+    }
+
+  ret = EX_UNAVAILABLE;
+
+  if (!pv_cheap_tree_copy (argv[1], argv[2], error))
+    goto out;
+
+  ret = 0;
+
+out:
+  if (local_error != NULL)
+    g_warning ("%s", local_error->message);
+
+  return ret;
+}
diff --git a/tests/cheap-copy.py b/tests/cheap-copy.py
new file mode 100755
index 0000000000000000000000000000000000000000..c01f4ffe1e936001fa190d7c57a8e51d4abe9e72
--- /dev/null
+++ b/tests/cheap-copy.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# Copyright 2020 Collabora Ltd.
+#
+# SPDX-License-Identifier: MIT
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+
+try:
+    import typing
+    typing      # placate pyflakes
+except ImportError:
+    pass
+
+
+class TestCheapCopy(unittest.TestCase):
+    def setUp(self) -> None:
+        self.G_TEST_SRCDIR = os.getenv(
+            'G_TEST_SRCDIR',
+            os.path.abspath(
+                os.path.join(os.path.dirname(__file__), os.pardir),
+            ),
+        )
+        self.G_TEST_BUILDDIR = os.getenv(
+            'G_TEST_BUILDDIR',
+            os.path.abspath('_build'),
+        )
+        self.cheap_copy = os.path.join(self.G_TEST_BUILDDIR, 'test-cheap-copy')
+
+    def assert_tree_is_superset(self, superset, subset):
+        for path, dirs, files in os.walk(subset):
+            equivalent = os.path.join(superset, os.path.relpath(path, subset))
+
+            for d in dirs:
+                if not os.path.isdir(os.path.join(equivalent, d)):
+                    raise AssertionError(
+                        '%r should be a directory', equivalent)
+
+            for f in files:
+                in_subset = os.path.join(path, f)
+                if (
+                    os.path.islink(in_subset)
+                    or not os.path.exists(in_subset)
+                ):
+                    target = os.readlink(in_subset)
+                    target2 = os.readlink(os.path.join(equivalent, f))
+                    self.assertEqual(target, target2)
+                else:
+                    info = os.stat(in_subset)
+                    info2 = os.stat(os.path.join(equivalent, f))
+                    # they should be hard links
+                    self.assertEqual(info.st_ino, info2.st_ino)
+                    self.assertEqual(info.st_dev, info2.st_dev)
+
+    def assert_tree_is_same(self, left, right):
+        self.assert_tree_is_superset(left, right)
+        self.assert_tree_is_superset(right, left)
+
+    def test_empty(self) -> None:
+        with tempfile.TemporaryDirectory(
+        ) as source, tempfile.TemporaryDirectory(
+        ) as dest:
+            subprocess.run(
+                [
+                    self.cheap_copy,
+                    source,
+                    dest,
+                ],
+                check=True,
+            )
+            self.assert_tree_is_same(source, dest)
+
+    def test_create(self) -> None:
+        with tempfile.TemporaryDirectory(
+        ) as source, tempfile.TemporaryDirectory(
+        ) as parent:
+            dest = os.path.join(parent, 'dest')
+            subprocess.run(
+                [
+                    self.cheap_copy,
+                    source,
+                    dest,
+                ],
+                check=True,
+            )
+            self.assert_tree_is_same(source, dest)
+
+    def test_populated(self) -> None:
+        with tempfile.TemporaryDirectory(
+        ) as source, tempfile.TemporaryDirectory(
+        ) as parent:
+            os.makedirs(os.path.join(source, 'a', 'b', 'c'))
+            os.makedirs(os.path.join(source, 'files'))
+
+            with open(os.path.join(source, 'x'), 'w') as writer:
+                writer.write('hello')
+
+            with open(os.path.join(source, 'files', 'y'), 'w') as writer:
+                writer.write('hello')
+
+            os.symlink('y', os.path.join(source, 'files', 'exists'))
+            os.symlink('/dev', os.path.join(source, 'files', 'dev'))
+            os.symlink('no', os.path.join(source, 'files', 'not here'))
+
+            dest = os.path.join(parent, 'dest')
+            subprocess.run(
+                [
+                    self.cheap_copy,
+                    source,
+                    dest,
+                ],
+                check=True,
+            )
+            self.assert_tree_is_same(source, dest)
+
+    def tearDown(self) -> None:
+        pass
+
+
+if __name__ == '__main__':
+    try:
+        from tap.runner import TAPTestRunner
+    except ImportError:
+        TAPTestRunner = None    # type: ignore
+
+    if TAPTestRunner is not None:
+        runner = TAPTestRunner()
+        runner.set_stream(True)
+        unittest.main(testRunner=runner)
+    else:
+        print('1..1')
+        program = unittest.main(exit=False)
+        if program.result.wasSuccessful():
+            print(
+                'ok 1 - %r (tap module not available)'
+                % program.result
+            )
+        else:
+            print(
+                'not ok 1 - %r (tap module not available)'
+                % program.result
+            )
+
+# vi: set sw=4 sts=4 et: