Browse Source

import: check exact size of copied data

The import will error out if there is not enough data, or too
much data provided.
Pawel Marczewski 4 years ago
parent
commit
e9b97e42b1

+ 35 - 10
qubes-rpc/admin.vm.volume.Import

@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 #
 #
 # This Admin API call is implemented as a custom script, instead of dumb
 # This Admin API call is implemented as a custom script, instead of dumb
 # passthrough to qubesd because it may get huge amount of data (whole root.img
 # passthrough to qubesd because it may get huge amount of data (whole root.img
@@ -32,39 +32,64 @@
 
 
 set -e
 set -e
 
 
+# make dd output consistent
+export LC_ALL=C
+
 # use temporary file, because env variables deal poorly with \0 inside
 # use temporary file, because env variables deal poorly with \0 inside
 tmpfile=$(mktemp)
 tmpfile=$(mktemp)
-trap "rm -f $tmpfile" EXIT
+trap 'rm -f $tmpfile' EXIT
 
 
 requested_size=""
 requested_size=""
-if [[ ${0##*/} == admin.vm.volume.ImportWithSize ]]; then
-    read requested_size
+if [[ "${0##*/}" == admin.vm.volume.ImportWithSize ]]; then
+    read -r requested_size
 fi
 fi
 
 
 echo -n "$requested_size" | qubesd-query -c /var/run/qubesd.internal.sock \
 echo -n "$requested_size" | qubesd-query -c /var/run/qubesd.internal.sock \
     "$QREXEC_REMOTE_DOMAIN" \
     "$QREXEC_REMOTE_DOMAIN" \
     "internal.vm.volume.ImportBegin" \
     "internal.vm.volume.ImportBegin" \
     "$QREXEC_REQUESTED_TARGET" \
     "$QREXEC_REQUESTED_TARGET" \
-    "$1" >$tmpfile
+    "$1" >"$tmpfile"
 
 
 # exit if qubesd returned an error (not '0\0')
 # exit if qubesd returned an error (not '0\0')
-if [ "$(head -c 2 $tmpfile | xxd -p)" != "3000" ]; then
+if [ "$(head -c 2 "$tmpfile" | xxd -p)" != "3000" ]; then
     cat "$tmpfile"
     cat "$tmpfile"
     exit 1
     exit 1
 fi
 fi
 size=$(tail -c +3 "$tmpfile"|cut -d ' ' -f 1)
 size=$(tail -c +3 "$tmpfile"|cut -d ' ' -f 1)
 path=$(tail -c +3 "$tmpfile"|cut -d ' ' -f 2)
 path=$(tail -c +3 "$tmpfile"|cut -d ' ' -f 2)
 
 
+error=""
+
 # now process stdin into this path
 # now process stdin into this path
-if sudo dd bs=128K of="$path" count="$size" iflag=count_bytes,fullblock \
-        conv=sparse,notrunc,nocreat,fdatasync status=none; then
+if ! sudo dd bs=128K of="$path" count="$size" iflag=count_bytes,fullblock \
+        conv=sparse,notrunc,nocreat,fdatasync 2>"$tmpfile"; then
+    error="error copying data"
+fi
+
+# Examine dd's output and check if number of bytes copied matches
+if [ -z "$error" ]; then
+    bytes_copied=$(tail -n1 "$tmpfile" | cut -d ' ' -f 1)
+    if [ "$bytes_copied" -ne "$size" ]; then
+        error="not enough data (copied $bytes_copied bytes, expected $size bytes)"
+    fi
+fi
+
+# Check if there is nothing more to be read from stdin
+if [ -z "$error" ]; then
+    if ! dd of="$tmpfile" bs=1 count=1 status=none || \
+            [ "$(stat -c %s "$tmpfile")" -ne 0 ]; then
+        error="too much data (expected $size bytes)"
+    fi
+fi
+
+if [ -z "$error" ]; then
     status="ok"
     status="ok"
 else
 else
-    status="fail"
+    status="fail\n$error"
 fi
 fi
 
 
 # send status notification to qubesd, and pass its response to the caller
 # send status notification to qubesd, and pass its response to the caller
-echo -n "$status" | qubesd-query -c /var/run/qubesd.internal.sock \
+echo -ne "$status" | qubesd-query -c /var/run/qubesd.internal.sock \
     "$QREXEC_REMOTE_DOMAIN" \
     "$QREXEC_REMOTE_DOMAIN" \
     "internal.vm.volume.ImportEnd" \
     "internal.vm.volume.ImportEnd" \
     "$QREXEC_REQUESTED_TARGET" \
     "$QREXEC_REQUESTED_TARGET" \

+ 8 - 1
qubes/api/internal.py

@@ -118,6 +118,8 @@ class QubesInternalAPI(qubes.api.AbstractQubesAPI):
         This is second half of admin.vm.volume.Import handling. It is called
         This is second half of admin.vm.volume.Import handling. It is called
         when actual import is finished. Response from this method is sent do
         when actual import is finished. Response from this method is sent do
         the client (as a response for admin.vm.volume.Import call).
         the client (as a response for admin.vm.volume.Import call).
+
+        The payload is either 'ok', or 'fail\n<error message>'.
         '''
         '''
         self.enforce(self.arg in self.dest.volumes.keys())
         self.enforce(self.arg in self.dest.volumes.keys())
         success = untrusted_payload == b'ok'
         success = untrusted_payload == b'ok'
@@ -134,7 +136,12 @@ class QubesInternalAPI(qubes.api.AbstractQubesAPI):
             success=success)
             success=success)
 
 
         if not success:
         if not success:
-            raise qubes.exc.QubesException('Data import failed')
+            error = ''
+            parts = untrusted_payload.decode('ascii').split('\n', 1)
+            if len(parts) > 1:
+                error = parts[1]
+            raise qubes.exc.QubesException(
+                'Data import failed: {}'.format(error))
 
 
     @qubes.api.method('internal.SuspendPre', no_payload=True)
     @qubes.api.method('internal.SuspendPre', no_payload=True)
     @asyncio.coroutine
     @asyncio.coroutine

+ 1 - 0
qubes/tests/__init__.py

@@ -1403,6 +1403,7 @@ def load_tests(loader, tests, pattern): # pylint: disable=unused-argument
             'qubes.tests.api_admin',
             'qubes.tests.api_admin',
             'qubes.tests.api_misc',
             'qubes.tests.api_misc',
             'qubes.tests.api_internal',
             'qubes.tests.api_internal',
+            'qubes.tests.rpc_import',
             ):
             ):
         tests.addTests(loader.loadTestsFromName(modname))
         tests.addTests(loader.loadTestsFromName(modname))
 
 

+ 22 - 0
qubes/tests/api_admin.py

@@ -1796,6 +1796,28 @@ netvm default=True type=vm \n'''
         self.assertEventFired(
         self.assertEventFired(
             self.emitter, 'admin-permission:admin.vm.volume.ImportWithSize')
             self.emitter, 'admin-permission:admin.vm.volume.ImportWithSize')
 
 
+    def test_510_vm_volume_import_end_success(self):
+        import_data_end_mock, self.vm.storage.import_data_end = \
+            self.coroutine_mock()
+        self.call_internal_mgmt_func(
+            b'internal.vm.volume.ImportEnd', b'test-vm1', b'private',
+            payload=b'ok')
+        self.assertEqual(import_data_end_mock.mock_calls, [
+            unittest.mock.call('private', success=True)
+        ])
+
+    def test_510_vm_volume_import_end_failure(self):
+        import_data_end_mock, self.vm.storage.import_data_end = \
+            self.coroutine_mock()
+        with self.assertRaisesRegexp(
+                qubes.exc.QubesException, 'error message'):
+            self.call_internal_mgmt_func(
+                b'internal.vm.volume.ImportEnd', b'test-vm1', b'private',
+                payload=b'fail\nerror message')
+        self.assertEqual(import_data_end_mock.mock_calls, [
+            unittest.mock.call('private', success=False)
+        ])
+
     def setup_for_clone(self):
     def setup_for_clone(self):
         self.pool = unittest.mock.MagicMock()
         self.pool = unittest.mock.MagicMock()
         self.app.pools['test'] = self.pool
         self.app.pools['test'] = self.pool

+ 180 - 0
qubes/tests/rpc_import.py

@@ -0,0 +1,180 @@
+
+#
+# The Qubes OS Project, https://www.qubes-os.org/
+#
+# Copyright (C) 2020 Paweł Marczewski <pawel@invisiblethingslab.com>
+#
+# This library 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 <https://www.gnu.org/licenses/>.
+#
+
+import unittest
+import tempfile
+import shutil
+import os
+import subprocess
+
+
+import qubes.tests
+
+
+class TestRpcImport(qubes.tests.QubesTestCase):
+    '''
+    Tests for qubes-rpc/admin.vm.volume.Import script.
+
+    It is a shell script that calls internal API methods via qubesd-query.
+    These tests mock all the calls.
+    '''
+
+
+    QUBESD_QUERY = '''\
+#!/bin/sh -e
+
+method=$4
+echo "$@" > command-$method
+cat > payload-$method
+cat response-$method
+'''
+
+    RPC_FILE_PATH = os.path.abspath(os.path.join(
+        os.path.dirname(__file__),
+        '../../qubes-rpc/admin.vm.volume.Import'))
+
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tmpdir)
+        with open(os.path.join(self.tmpdir, 'qubesd-query'), 'w') \
+             as qubesd_query_f:
+            qubesd_query_f.write(self.QUBESD_QUERY)
+        os.chmod(os.path.join(self.tmpdir, 'qubesd-query'), 0o700)
+
+        shutil.copy(
+            self.RPC_FILE_PATH,
+            os.path.join(self.tmpdir, 'admin.vm.volume.Import'))
+        shutil.copy(
+            self.RPC_FILE_PATH,
+            os.path.join(self.tmpdir, 'admin.vm.volume.ImportWithSize'))
+
+    # pylint:disable=invalid-name
+    def mockMethod(self, method, response):
+        with open(os.path.join(self.tmpdir, 'response-' + method), 'wb') \
+             as response_f:
+            response_f.write(response)
+
+    # pylint:disable=invalid-name
+    def assertMethodCalled(self, method, arg, expected_payload=b''):
+        try:
+            with open(os.path.join(self.tmpdir, 'command-' + method), 'rb') \
+                 as command_f:
+                command = command_f.read()
+            with open(os.path.join(self.tmpdir, 'payload-' + method), 'rb') \
+                 as payload_f:
+                payload = payload_f.read()
+        except FileNotFoundError:
+            self.fail('{} was not called'.format(method))
+
+        self.assertListEqual(command.decode().split(), [
+            '-c', '/var/run/qubesd.internal.sock',
+            'remote', method, 'target', arg
+        ])
+        self.assertEqual(payload, expected_payload)
+
+    # pylint:disable=invalid-name
+    def assertFileData(self, path, expected_data):
+        with open(path, 'rb') as data_f:
+            data = data_f.read()
+        self.assertEquals(data, expected_data)
+
+    def setup_import(self, size):
+        self.target = os.path.join(self.tmpdir, 'target')
+        os.mknod(self.target)
+
+        self.mockMethod(
+            'internal.vm.volume.ImportBegin',
+            '\x30\x00{} {}'.format(size, self.target).encode())
+
+        self.mockMethod(
+            'internal.vm.volume.ImportEnd',
+            b'\x30\x00import-end')
+
+    def run_rpc(self, command, arg, data):
+        with open(os.path.join(self.tmpdir, 'data'), 'w+b') as data_f:
+            data_f.write(data)
+            data_f.seek(0)
+            env = {
+                'PATH': self.tmpdir + ':' + os.getenv('PATH'),
+                'QREXEC_REMOTE_DOMAIN': 'remote',
+                'QREXEC_REQUESTED_TARGET': 'target',
+            }
+            output = subprocess.check_output(
+                [command, arg],
+                env=env,
+                cwd=self.tmpdir,
+                stdin=data_f
+            )
+
+        self.assertEqual(output, b'\x30\x00import-end')
+
+    def test_00_import(self):
+        data = b'abcd' * 1024
+        size = len(data)
+        self.setup_import(size)
+
+        self.run_rpc('admin.vm.volume.Import', 'volume', data)
+
+        self.assertMethodCalled('internal.vm.volume.ImportBegin', 'volume')
+        self.assertMethodCalled('internal.vm.volume.ImportEnd', 'volume',
+                                b'ok')
+        self.assertFileData(self.target, data)
+
+    def test_01_import_with_size(self):
+        data = b'abcd' * 1024
+        size = len(data)
+        self.setup_import(size)
+
+        self.run_rpc('admin.vm.volume.ImportWithSize', 'volume',
+                     str(size).encode() + b'\n' + data)
+
+        self.assertMethodCalled('internal.vm.volume.ImportBegin', 'volume',
+                                str(size).encode())
+        self.assertMethodCalled('internal.vm.volume.ImportEnd', 'volume',
+                                b'ok')
+        self.assertFileData(self.target, data)
+
+    def test_02_import_not_enough_data(self):
+        data = b'abcd' * 1024
+        size = len(data) + 1
+        self.setup_import(size)
+
+        self.run_rpc('admin.vm.volume.Import', 'volume', data)
+
+        self.assertMethodCalled('internal.vm.volume.ImportBegin', 'volume')
+        self.assertMethodCalled(
+            'internal.vm.volume.ImportEnd', 'volume',
+            b'fail\n' +
+            ('not enough data (copied {} bytes, expected {} bytes)'
+             .format(len(data), size).encode()))
+
+    def test_03_import_too_much_data(self):
+        data = b'abcd' * 1024
+        size = len(data) - 1
+        self.setup_import(size)
+
+        output = self.run_rpc('admin.vm.volume.Import', 'volume', data)
+
+        self.assertMethodCalled('internal.vm.volume.ImportBegin', 'volume')
+        self.assertMethodCalled(
+            'internal.vm.volume.ImportEnd', 'volume',
+            b'fail\n' +
+            ('too much data (expected {} bytes)'
+             .format(size).encode()))

+ 1 - 0
rpm_spec/core-dom0.spec.in

@@ -300,6 +300,7 @@ fi
 %{python3_sitelib}/qubes/tests/ext.py
 %{python3_sitelib}/qubes/tests/ext.py
 %{python3_sitelib}/qubes/tests/firewall.py
 %{python3_sitelib}/qubes/tests/firewall.py
 %{python3_sitelib}/qubes/tests/init.py
 %{python3_sitelib}/qubes/tests/init.py
+%{python3_sitelib}/qubes/tests/rpc_import.py
 %{python3_sitelib}/qubes/tests/storage.py
 %{python3_sitelib}/qubes/tests/storage.py
 %{python3_sitelib}/qubes/tests/storage_file.py
 %{python3_sitelib}/qubes/tests/storage_file.py
 %{python3_sitelib}/qubes/tests/storage_reflink.py
 %{python3_sitelib}/qubes/tests/storage_reflink.py