Merge remote-tracking branch 'origin/pr/309'

* origin/pr/309:
  import: check exact size of copied data
  Implement new admin.vm.ImportWithSize API call

Fixes QubesOS/qubes-issues#5239
This commit is contained in:
Marek Marczykowski-Górecki 2020-01-24 01:35:00 +01:00
commit edf5977b23
No known key found for this signature in database
GPG Key ID: 063938BA42CFA724
17 changed files with 469 additions and 73 deletions

View File

@ -191,6 +191,8 @@ endif
cp qubes-rpc-policy/qubes.GetDate.policy $(DESTDIR)/etc/qubes-rpc/policy/qubes.GetDate cp qubes-rpc-policy/qubes.GetDate.policy $(DESTDIR)/etc/qubes-rpc/policy/qubes.GetDate
cp qubes-rpc-policy/qubes.ConnectTCP.policy $(DESTDIR)/etc/qubes-rpc/policy/qubes.ConnectTCP cp qubes-rpc-policy/qubes.ConnectTCP.policy $(DESTDIR)/etc/qubes-rpc/policy/qubes.ConnectTCP
cp qubes-rpc-policy/admin.vm.Console.policy $(DESTDIR)/etc/qubes-rpc/policy/admin.vm.Console cp qubes-rpc-policy/admin.vm.Console.policy $(DESTDIR)/etc/qubes-rpc/policy/admin.vm.Console
cp qubes-rpc-policy/admin.vm.volume.Import.policy $(DESTDIR)/etc/qubes-rpc/policy/admin.vm.volume.Import
cp qubes-rpc-policy/admin.vm.volume.ImportWithSize.policy $(DESTDIR)/etc/qubes-rpc/policy/admin.vm.volume.ImportWithSize
cp qubes-rpc-policy/policy.RegisterArgument.policy $(DESTDIR)/etc/qubes-rpc/policy/policy.RegisterArgument cp qubes-rpc-policy/policy.RegisterArgument.policy $(DESTDIR)/etc/qubes-rpc/policy/policy.RegisterArgument
cp qubes-rpc/qubes.FeaturesRequest $(DESTDIR)/etc/qubes-rpc/ cp qubes-rpc/qubes.FeaturesRequest $(DESTDIR)/etc/qubes-rpc/
cp qubes-rpc/qubes.GetDate $(DESTDIR)/etc/qubes-rpc/ cp qubes-rpc/qubes.GetDate $(DESTDIR)/etc/qubes-rpc/
@ -208,6 +210,7 @@ endif
$(DESTDIR)/etc/qubes-rpc/$$method || exit 1; \ $(DESTDIR)/etc/qubes-rpc/$$method || exit 1; \
done done
install qubes-rpc/admin.vm.volume.Import $(DESTDIR)/etc/qubes-rpc/ install qubes-rpc/admin.vm.volume.Import $(DESTDIR)/etc/qubes-rpc/
ln -s admin.vm.volume.Import $(DESTDIR)/etc/qubes-rpc/admin.vm.volume.ImportWithSize
install qubes-rpc/admin.vm.Console $(DESTDIR)/etc/qubes-rpc/ install qubes-rpc/admin.vm.Console $(DESTDIR)/etc/qubes-rpc/
PYTHONPATH=.:test-packages qubes-rpc-policy/generate-admin-policy \ PYTHONPATH=.:test-packages qubes-rpc-policy/generate-admin-policy \
--destdir=$(DESTDIR)/etc/qubes-rpc/policy \ --destdir=$(DESTDIR)/etc/qubes-rpc/policy \
@ -260,4 +263,3 @@ msi:
candle -arch x64 -dversion=$(VERSION) installer.wxs candle -arch x64 -dversion=$(VERSION) installer.wxs
light -b destinstdir -o core-admin.msm installer.wixobj light -b destinstdir -o core-admin.msm installer.wixobj
rm -rf destinstdir rm -rf destinstdir

View File

@ -0,0 +1,13 @@
## Note that policy parsing stops at the first match.
## Anything not specifically allowed here (or in included file) will be denied.
## Please use a single # to start your custom comments
## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
## Include a common file for all admin.* methods to ease setting up
## Management VM.
## To allow only specific actions, edit specific policy file, like this one. To
## allow all of them, edit appropriate /etc/qubes-rpc/include/admin-*.
$include:include/admin-local-rwx

View File

@ -0,0 +1,13 @@
## Note that policy parsing stops at the first match.
## Anything not specifically allowed here (or in included file) will be denied.
## Please use a single # to start your custom comments
## Add your entries here, make sure to append ",target=dom0" to all allow/ask actions
## Include a common file for all admin.* methods to ease setting up
## Management VM.
## To allow only specific actions, edit specific policy file, like this one. To
## allow all of them, edit appropriate /etc/qubes-rpc/include/admin-*.
$include:include/admin-local-rwx

View File

@ -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
@ -16,42 +16,80 @@
# 2. Actual data import (done by this script, using dd) # 2. Actual data import (done by this script, using dd)
# 3. Report final result, produce final response to the caller (done by # 3. Report final result, produce final response to the caller (done by
# qubesd) # qubesd)
# #
# This way we do not pass all the data through qubesd, but still can # This way we do not pass all the data through qubesd, but still can
# control the process from there in a meaningful way. Note that the last # control the process from there in a meaningful way. Note that the last
# part (second call to qubesd) may perform all kind of verification (like # part (second call to qubesd) may perform all kind of verification (like
# a signature check on the data, or so) and can also prevent VM from # a signature check on the data, or so) and can also prevent VM from
# starting (hooking also domain-pre-start event) from not verified image. # starting (hooking also domain-pre-start event) from not verified image.
#
# Note that this script implements two calls:
# - admin.vm.volume.Import
# - admin.vm.volume.ImportWithSize
# In the case of admin.vm.ImportWithSize, the first line of payload is then
# data size in bytes. This is so that we can already notify qubesd to create a
# volume with the new size.
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
qubesd-query -e \
"$QREXEC_REMOTE_DOMAIN" \ requested_size=""
"admin.vm.volume.Import" \ if [[ "${0##*/}" == admin.vm.volume.ImportWithSize ]]; then
"$QREXEC_REQUESTED_TARGET" \ read -r requested_size
"$1" >$tmpfile fi
echo -n "$requested_size" | qubesd-query -c /var/run/qubesd.internal.sock \
"$QREXEC_REMOTE_DOMAIN" \
"internal.vm.volume.ImportBegin" \
"$QREXEC_REQUESTED_TARGET" \
"$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 \ if ! sudo dd bs=128K of="$path" count="$size" iflag=count_bytes,fullblock \
conv=sparse,notrunc,nocreat,fdatasync status=none; then 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" \

View File

@ -486,36 +486,6 @@ class QubesAdminAPI(qubes.api.AbstractQubesAPI):
finally: # even if calling qubes.ResizeDisk inside the VM failed finally: # even if calling qubes.ResizeDisk inside the VM failed
self.app.save() self.app.save()
@qubes.api.method('admin.vm.volume.Import', no_payload=True,
scope='local', write=True)
@asyncio.coroutine
def vm_volume_import(self):
"""Import volume data.
Note that this function only returns a path to where data should be
written, actual importing is done by a script in /etc/qubes-rpc
When the script finish importing, it will trigger
internal.vm.volume.ImportEnd (with either b'ok' or b'fail' as a
payload) and response from that call will be actually send to the
caller.
"""
self.enforce(self.arg in self.dest.volumes.keys())
self.fire_event_for_permission()
if not self.dest.is_halted():
raise qubes.exc.QubesVMNotHaltedError(self.dest)
path = yield from self.dest.storage.import_data(self.arg)
self.enforce(' ' not in path)
size = self.dest.volumes[self.arg].size
# when we know the action is allowed, inform extensions that it will
# be performed
self.dest.fire_event('domain-volume-import-begin', volume=self.arg)
return '{} {}'.format(size, path)
@qubes.api.method('admin.vm.volume.Set.revisions_to_keep', @qubes.api.method('admin.vm.volume.Set.revisions_to_keep',
scope='local', write=True) scope='local', write=True)
@asyncio.coroutine @asyncio.coroutine

View File

@ -1,4 +1,4 @@
# -*- encoding: utf8 -*- # -*- encoding: utf-8 -*-
# #
# The Qubes OS Project, http://www.qubes-os.org # The Qubes OS Project, http://www.qubes-os.org
# #
@ -56,6 +56,61 @@ class QubesInternalAPI(qubes.api.AbstractQubesAPI):
return json.dumps(system_info) return json.dumps(system_info)
@qubes.api.method('internal.vm.volume.ImportBegin',
scope='local', write=True)
@asyncio.coroutine
def vm_volume_import(self, untrusted_payload):
"""Begin importing volume data. Payload is either size of new data
in bytes, or empty. If empty, the current volume's size will be used.
Returns size and path to where data should be written.
Triggered by scripts in /etc/qubes-rpc:
admin.vm.volume.Import, admin.vm.volume.ImportWithSize.
When the script finish importing, it will trigger
internal.vm.volume.ImportEnd (with either b'ok' or b'fail' as a
payload) and response from that call will be actually send to the
caller.
"""
self.enforce(self.arg in self.dest.volumes.keys())
if untrusted_payload:
original_method = 'admin.vm.volume.ImportWithSize'
else:
original_method = 'admin.vm.volume.Import'
self.src.fire_event(
'admin-permission:' + original_method,
pre_event=True, dest=self.dest, arg=self.arg)
if not self.dest.is_halted():
raise qubes.exc.QubesVMNotHaltedError(self.dest)
requested_size = None
if untrusted_payload:
try:
untrusted_value = int(untrusted_payload.decode('ascii'))
except (UnicodeDecodeError, ValueError):
raise qubes.api.ProtocolError('Invalid value')
self.enforce(untrusted_value > 0)
requested_size = untrusted_value
del untrusted_value
del untrusted_payload
path = yield from self.dest.storage.import_data(
self.arg, requested_size)
self.enforce(' ' not in path)
if requested_size is None:
size = self.dest.volumes[self.arg].size
else:
size = requested_size
# when we know the action is allowed, inform extensions that it will
# be performed
self.dest.fire_event(
'domain-volume-import-begin', volume=self.arg, size=size)
return '{} {}'.format(size, path)
@qubes.api.method('internal.vm.volume.ImportEnd') @qubes.api.method('internal.vm.volume.ImportEnd')
@asyncio.coroutine @asyncio.coroutine
def vm_volume_import_end(self, untrusted_payload): def vm_volume_import_end(self, untrusted_payload):
@ -63,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'
@ -79,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

View File

@ -188,7 +188,7 @@ class Volume:
''' '''
raise self._not_implemented("export") raise self._not_implemented("export")
def import_data(self): def import_data(self, size):
''' Returns a path to overwrite volume data. ''' Returns a path to overwrite volume data.
This method is called after volume was already :py:meth:`create`-ed. This method is called after volume was already :py:meth:`create`-ed.
@ -199,6 +199,8 @@ class Volume:
on the fly), the returned path may be a pipe. on the fly), the returned path may be a pipe.
This can be implemented as a coroutine. This can be implemented as a coroutine.
:param int size: size of new data in bytes
''' '''
raise self._not_implemented("import_data") raise self._not_implemented("import_data")
@ -624,14 +626,22 @@ class Storage:
return self.vm.volumes[volume].export() return self.vm.volumes[volume].export()
@asyncio.coroutine @asyncio.coroutine
def import_data(self, volume): def import_data(self, volume, size):
''' Helper function to import volume data (pool.import_data(volume))''' '''
Helper function to import volume data (pool.import_data(volume)).
:size: new size in bytes, or None if using old size
'''
assert isinstance(volume, (Volume, str)), \ assert isinstance(volume, (Volume, str)), \
"You need to pass a Volume or pool name as str" "You need to pass a Volume or pool name as str"
if isinstance(volume, Volume): if isinstance(volume, str):
ret = volume.import_data() volume = self.vm.volumes[volume]
else:
ret = self.vm.volumes[volume].import_data() if size is None:
size = volume.size
ret = volume.import_data(size)
return (yield from qubes.utils.coro_maybe(ret)) return (yield from qubes.utils.coro_maybe(ret))
@asyncio.coroutine @asyncio.coroutine

View File

@ -283,12 +283,12 @@ class FileVolume(qubes.storage.Volume):
copy_file(src_volume.export(), self.path) copy_file(src_volume.export(), self.path)
return self return self
def import_data(self): def import_data(self, size):
if not self.save_on_stop: if not self.save_on_stop:
raise qubes.storage.StoragePoolException( raise qubes.storage.StoragePoolException(
"Can not import into save_on_stop=False volume {!s}".format( "Can not import into save_on_stop=False volume {!s}".format(
self)) self))
create_sparse_file(self.path_import, self.size) create_sparse_file(self.path_import, size)
return self.path_import return self.path_import
def import_data_end(self, success): def import_data_end(self, success):

View File

@ -560,7 +560,7 @@ class ThinVolume(qubes.storage.Volume):
@locked @locked
@asyncio.coroutine @asyncio.coroutine
def import_data(self): def import_data(self, size):
''' Returns an object that can be `open()`. ''' ''' Returns an object that can be `open()`. '''
if self.is_dirty(): if self.is_dirty():
raise qubes.storage.StoragePoolException( raise qubes.storage.StoragePoolException(
@ -569,7 +569,7 @@ class ThinVolume(qubes.storage.Volume):
self.abort_if_import_in_progress() self.abort_if_import_in_progress()
# pylint: disable=protected-access # pylint: disable=protected-access
cmd = ['create', self.pool._pool_id, self._vid_import.split('/')[1], cmd = ['create', self.pool._pool_id, self._vid_import.split('/')[1],
str(self.size)] str(size)]
yield from qubes_lvm_coro(cmd, self.log) yield from qubes_lvm_coro(cmd, self.log)
yield from reset_cache_coro() yield from reset_cache_coro()
devpath = '/dev/' + self._vid_import devpath = '/dev/' + self._vid_import

View File

@ -289,11 +289,11 @@ class ReflinkVolume(qubes.storage.Volume):
@_coroutinized @_coroutinized
@_locked @_locked
def import_data(self): def import_data(self, size):
if not self.save_on_stop: if not self.save_on_stop:
raise NotImplementedError( raise NotImplementedError(
'Cannot import_data: {} is not save_on_stop'.format(self.vid)) 'Cannot import_data: {} is not save_on_stop'.format(self.vid))
_create_sparse_file(self._path_import, self._get_size()) _create_sparse_file(self._path_import, size)
return self._path_import return self._path_import
def _import_data_end(self, success): def _import_data_end(self, success):

View File

@ -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))

View File

@ -34,6 +34,7 @@ import qubes
import qubes.devices import qubes.devices
import qubes.firewall import qubes.firewall
import qubes.api.admin import qubes.api.admin
import qubes.api.internal
import qubes.tests import qubes.tests
import qubes.storage import qubes.storage
@ -113,6 +114,13 @@ class AdminAPITestCase(qubes.tests.QubesTestCase):
'admin-permission:' + method.decode('ascii')) 'admin-permission:' + method.decode('ascii'))
return response return response
def call_internal_mgmt_func(self, method, dest, arg=b'', payload=b''):
mgmt_obj = qubes.api.internal.QubesInternalAPI(self.app, b'dom0', method, dest, arg)
loop = asyncio.get_event_loop()
response = loop.run_until_complete(
mgmt_obj.execute(untrusted_payload=payload))
return response
class TC_00_VMs(AdminAPITestCase): class TC_00_VMs(AdminAPITestCase):
def test_000_vm_list(self): def test_000_vm_list(self):
@ -207,11 +215,9 @@ qid default=False type=int 2
qrexec_timeout default=True type=int 60 qrexec_timeout default=True type=int 60
updateable default=True type=bool False updateable default=True type=bool False
kernelopts default=False type=str opt1\\nopt2\\nopt3\\\\opt4 kernelopts default=False type=str opt1\\nopt2\\nopt3\\\\opt4
netvm default=True type=vm netvm default=True type=vm \n'''
'''
self.assertEqual(value, expected) self.assertEqual(value, expected)
def test_030_vm_property_set_vm(self): def test_030_vm_property_set_vm(self):
netvm = self.app.add_new_vm('AppVM', label='red', name='test-net', netvm = self.app.add_new_vm('AppVM', label='red', name='test-net',
template='test-template', provides_network=True) template='test-template', provides_network=True)
@ -1763,9 +1769,12 @@ netvm default=True type=vm
self.assertFalse(mock_remove.called) self.assertFalse(mock_remove.called)
self.assertFalse(self.app.save.called) self.assertFalse(self.app.save.called)
# Import tests
# (internal methods, normally called from qubes-rpc script)
def test_510_vm_volume_import(self): def test_510_vm_volume_import(self):
value = self.call_mgmt_func(b'admin.vm.volume.Import', b'test-vm1', value = self.call_internal_mgmt_func(
b'private') b'internal.vm.volume.ImportBegin', b'test-vm1', b'private')
self.assertEqual(value, '{} {}'.format( self.assertEqual(value, '{} {}'.format(
2*2**30, '/tmp/qubes-test-dir/appvms/test-vm1/private-import.img')) 2*2**30, '/tmp/qubes-test-dir/appvms/test-vm1/private-import.img'))
self.assertFalse(self.app.save.called) self.assertFalse(self.app.save.called)
@ -1774,8 +1783,56 @@ netvm default=True type=vm
with unittest.mock.patch.object( with unittest.mock.patch.object(
self.vm, 'get_power_state', lambda: 'Running'): self.vm, 'get_power_state', lambda: 'Running'):
with self.assertRaises(qubes.exc.QubesVMNotHaltedError): with self.assertRaises(qubes.exc.QubesVMNotHaltedError):
self.call_mgmt_func(b'admin.vm.volume.Import', b'test-vm1', self.call_internal_mgmt_func(
b'private') b'internal.vm.volume.ImportBegin', b'test-vm1', b'private')
def test_512_vm_volume_import_with_size(self):
new_size = 4 * 2**30
file_name = '/tmp/qubes-test-dir/appvms/test-vm1/private-import.img'
value = self.call_internal_mgmt_func(
b'internal.vm.volume.ImportBegin', b'test-vm1',
b'private', payload=str(new_size).encode())
self.assertEqual(value, '{} {}'.format(
new_size, file_name))
self.assertFalse(self.app.save.called)
self.assertEqual(os.stat(file_name).st_size, new_size)
def test_515_vm_volume_import_fire_event(self):
self.call_internal_mgmt_func(
b'internal.vm.volume.ImportBegin', b'test-vm1', b'private')
self.assertEventFired(
self.emitter, 'admin-permission:admin.vm.volume.Import')
def test_516_vm_volume_import_fire_event_with_size(self):
self.call_internal_mgmt_func(
b'internal.vm.volume.ImportBegin', b'test-vm1', b'private',
b'123')
self.assertEventFired(
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()

180
qubes/tests/rpc_import.py Normal file
View File

@ -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()))

View File

@ -332,7 +332,7 @@ class TC_01_FileVolumes(qubes.tests.QubesTestCase):
vm = qubes.tests.storage.TestVM(self) vm = qubes.tests.storage.TestVM(self)
volume = self.app.get_pool(self.POOL_NAME).init_volume(vm, config) volume = self.app.get_pool(self.POOL_NAME).init_volume(vm, config)
volume.create() volume.create()
import_path = volume.import_data() import_path = volume.import_data(volume.size)
self.assertNotEqual(volume.path, import_path) self.assertNotEqual(volume.path, import_path)
with open(import_path, 'w+') as import_file: with open(import_path, 'w+') as import_file:
import_file.write('test') import_file.write('test')
@ -353,7 +353,7 @@ class TC_01_FileVolumes(qubes.tests.QubesTestCase):
vm = qubes.tests.storage.TestVM(self) vm = qubes.tests.storage.TestVM(self)
volume = self.app.get_pool(self.POOL_NAME).init_volume(vm, config) volume = self.app.get_pool(self.POOL_NAME).init_volume(vm, config)
volume.create() volume.create()
import_path = volume.import_data() import_path = volume.import_data(volume.size)
self.assertNotEqual(volume.path, import_path) self.assertNotEqual(volume.path, import_path)
with open(import_path, 'w+') as import_file: with open(import_path, 'w+') as import_file:
import_file.write('test') import_file.write('test')
@ -376,7 +376,7 @@ class TC_01_FileVolumes(qubes.tests.QubesTestCase):
volume.create() volume.create()
with open(volume.path, 'w') as vol_file: with open(volume.path, 'w') as vol_file:
vol_file.write('test data') vol_file.write('test data')
import_path = volume.import_data() import_path = volume.import_data(volume.size)
self.assertNotEqual(volume.path, import_path) self.assertNotEqual(volume.path, import_path)
with open(import_path, 'w+'): with open(import_path, 'w+'):
pass pass
@ -402,6 +402,30 @@ class TC_01_FileVolumes(qubes.tests.QubesTestCase):
self.assertEqual(os.path.getsize(volume.path), new_size) self.assertEqual(os.path.getsize(volume.path), new_size)
self.assertEqual(volume.size, new_size) self.assertEqual(volume.size, new_size)
def test_024_import_data_with_new_size(self):
config = {
'name': 'root',
'pool': self.POOL_NAME,
'save_on_stop': True,
'rw': True,
'size': 1024 * 1024,
}
new_size = 2 * 1024 * 1024
vm = qubes.tests.storage.TestVM(self)
volume = self.app.get_pool(self.POOL_NAME).init_volume(vm, config)
volume.create()
import_path = volume.import_data(new_size)
self.assertNotEqual(volume.path, import_path)
with open(import_path, 'r+b') as import_file:
import_file.write(b'test')
volume.import_data_end(True)
self.assertFalse(os.path.exists(import_path), import_path)
with open(volume.path, 'rb') as volume_file:
volume_data = volume_file.read()
self.assertEqual(volume_data.strip(b'\0'), b'test')
self.assertEqual(len(volume_data), new_size)
def _get_loop_size(self, path): def _get_loop_size(self, path):
sudo = [] if os.getuid() == 0 else ['sudo'] sudo = [] if os.getuid() == 0 else ['sudo']
try: try:

View File

@ -698,7 +698,8 @@ class TC_00_ThinPool(ThinPoolBase):
self.loop.run_until_complete(volume.create()) self.loop.run_until_complete(volume.create())
current_uuid = self._get_lv_uuid(volume.path) current_uuid = self._get_lv_uuid(volume.path)
self.assertFalse(volume.is_dirty()) self.assertFalse(volume.is_dirty())
import_path = self.loop.run_until_complete(volume.import_data()) import_path = self.loop.run_until_complete(
volume.import_data(volume.size))
import_uuid = self._get_lv_uuid(import_path) import_uuid = self._get_lv_uuid(import_path)
self.assertNotEqual(current_uuid, import_uuid) self.assertNotEqual(current_uuid, import_uuid)
# success - commit data # success - commit data
@ -729,7 +730,8 @@ class TC_00_ThinPool(ThinPoolBase):
self.loop.run_until_complete(volume.create()) self.loop.run_until_complete(volume.create())
current_uuid = self._get_lv_uuid(volume.path) current_uuid = self._get_lv_uuid(volume.path)
self.assertFalse(volume.is_dirty()) self.assertFalse(volume.is_dirty())
import_path = self.loop.run_until_complete(volume.import_data()) import_path = self.loop.run_until_complete(
volume.import_data(volume.size))
import_uuid = self._get_lv_uuid(import_path) import_uuid = self._get_lv_uuid(import_path)
self.assertNotEqual(current_uuid, import_uuid) self.assertNotEqual(current_uuid, import_uuid)
# fail - discard data # fail - discard data
@ -860,7 +862,8 @@ class TC_00_ThinPool(ThinPoolBase):
'sudo', 'dd', 'if=/dev/urandom', 'of=' + volume.path, 'count=1', 'bs=1M' 'sudo', 'dd', 'if=/dev/urandom', 'of=' + volume.path, 'count=1', 'bs=1M'
)) ))
self.loop.run_until_complete(p.wait()) self.loop.run_until_complete(p.wait())
import_path = self.loop.run_until_complete(volume.import_data()) import_path = self.loop.run_until_complete(
volume.import_data(volume.size))
self.assertNotEqual(volume.path, import_path) self.assertNotEqual(volume.path, import_path)
p = self.loop.run_until_complete(asyncio.create_subprocess_exec( p = self.loop.run_until_complete(asyncio.create_subprocess_exec(
'sudo', 'touch', import_path)) 'sudo', 'touch', import_path))
@ -874,6 +877,28 @@ class TC_00_ThinPool(ThinPoolBase):
volume_data, _ = self.loop.run_until_complete(p.communicate()) volume_data, _ = self.loop.run_until_complete(p.communicate())
self.assertEqual(volume_data.strip(b'\0'), b'') self.assertEqual(volume_data.strip(b'\0'), b'')
def test_035_import_data_new_size(self):
''' Test volume import'''
config = {
'name': 'root',
'pool': self.pool.name,
'save_on_stop': True,
'rw': True,
'revisions_to_keep': 2,
'size': qubes.config.defaults['root_img_size'],
}
new_size = 2 * qubes.config.defaults['root_img_size']
vm = qubes.tests.storage.TestVM(self)
volume = self.app.get_pool(self.pool.name).init_volume(vm, config)
self.loop.run_until_complete(volume.create())
self.loop.run_until_complete(
volume.import_data(new_size))
self.loop.run_until_complete(volume.import_data_end(True))
self.assertEqual(volume.size, new_size)
self.loop.run_until_complete(volume.remove())
def test_040_volatile(self): def test_040_volatile(self):
'''Volatile volume test''' '''Volatile volume test'''
config = { config = {

View File

@ -128,7 +128,7 @@ class TC_10_ReflinkPool(qubes.tests.QubesTestCase):
self.loop.run_until_complete(volume.create()) self.loop.run_until_complete(volume.create())
with open(volume.export(), 'w') as vol_file: with open(volume.export(), 'w') as vol_file:
vol_file.write('test data') vol_file.write('test data')
import_path = self.loop.run_until_complete(volume.import_data()) import_path = self.loop.run_until_complete(volume.import_data(volume.size))
self.assertNotEqual(volume.path, import_path) self.assertNotEqual(volume.path, import_path)
with open(import_path, 'w+'): with open(import_path, 'w+'):
pass pass

View File

@ -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