Skip to content

Commit f8fa96f

Browse files
Zuulopenstack-gerrit
authored andcommitted
Merge "Additional qemu safety checking on base images" into unmaintained/zed
2 parents 8f702d7 + 303c2c9 commit f8fa96f

File tree

6 files changed

+185
-19
lines changed

6 files changed

+185
-19
lines changed

nova/image/format_inspector.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,23 @@ def safety_check(self):
368368
not self.has_unknown_features)
369369

370370

371+
class QEDInspector(FileInspector):
372+
def __init__(self, tracing=False):
373+
super().__init__(tracing)
374+
self.new_region('header', CaptureRegion(0, 512))
375+
376+
@property
377+
def format_match(self):
378+
if not self.region('header').complete:
379+
return False
380+
return self.region('header').data.startswith(b'QED\x00')
381+
382+
def safety_check(self):
383+
# QED format is not supported by anyone, but we want to detect it
384+
# and mark it as just always unsafe.
385+
return False
386+
387+
371388
# The VHD (or VPC as QEMU calls it) format consists of a big-endian
372389
# 512-byte "footer" at the beginning of the file with various
373390
# information, most of which does not matter to us:
@@ -871,19 +888,52 @@ def close(self):
871888
self._source.close()
872889

873890

891+
ALL_FORMATS = {
892+
'raw': FileInspector,
893+
'qcow2': QcowInspector,
894+
'vhd': VHDInspector,
895+
'vhdx': VHDXInspector,
896+
'vmdk': VMDKInspector,
897+
'vdi': VDIInspector,
898+
'qed': QEDInspector,
899+
}
900+
901+
874902
def get_inspector(format_name):
875903
"""Returns a FormatInspector class based on the given name.
876904
877905
:param format_name: The name of the disk_format (raw, qcow2, etc).
878906
:returns: A FormatInspector or None if unsupported.
879907
"""
880-
formats = {
881-
'raw': FileInspector,
882-
'qcow2': QcowInspector,
883-
'vhd': VHDInspector,
884-
'vhdx': VHDXInspector,
885-
'vmdk': VMDKInspector,
886-
'vdi': VDIInspector,
887-
}
888-
889-
return formats.get(format_name)
908+
909+
return ALL_FORMATS.get(format_name)
910+
911+
912+
def detect_file_format(filename):
913+
"""Attempts to detect the format of a file.
914+
915+
This runs through a file one time, running all the known inspectors in
916+
parallel. It stops reading the file once one of them matches or all of
917+
them are sure they don't match.
918+
919+
Returns the FileInspector that matched, if any. None if 'raw'.
920+
"""
921+
inspectors = {k: v() for k, v in ALL_FORMATS.items()}
922+
with open(filename, 'rb') as f:
923+
for chunk in chunked_reader(f):
924+
for format, inspector in list(inspectors.items()):
925+
try:
926+
inspector.eat_chunk(chunk)
927+
except ImageFormatError:
928+
# No match, so stop considering this format
929+
inspectors.pop(format)
930+
continue
931+
if (inspector.format_match and inspector.complete and
932+
format != 'raw'):
933+
# First complete match (other than raw) wins
934+
return inspector
935+
if all(i.complete for i in inspectors.values()):
936+
# If all the inspectors are sure they are not a match, avoid
937+
# reading to the end of the file to settle on 'raw'.
938+
break
939+
return inspectors['raw']

nova/tests/unit/virt/libvirt/test_driver.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14275,10 +14275,11 @@ def test_create_images_and_backing_images_exist(
1427514275
'/fake/instance/dir', disk_info)
1427614276
self.assertFalse(mock_fetch_image.called)
1427714277

14278+
@mock.patch('nova.image.format_inspector.detect_file_format')
1427814279
@mock.patch('nova.privsep.path.utime')
1427914280
@mock.patch('nova.virt.libvirt.utils.create_image')
1428014281
def test_create_images_and_backing_ephemeral_gets_created(
14281-
self, mock_create_cow_image, mock_utime):
14282+
self, mock_create_cow_image, mock_utime, mock_detect):
1428214283
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
1428314284

1428414285
base_dir = os.path.join(CONF.instances_path,
@@ -16018,11 +16019,13 @@ def test_create_ephemeral_specified_fs(self, fake_mkfs):
1601816019
fake_mkfs.assert_has_calls([mock.call('ext4', '/dev/something',
1601916020
'myVol')])
1602016021

16022+
@mock.patch('nova.image.format_inspector.detect_file_format')
1602116023
@mock.patch('nova.privsep.path.utime')
1602216024
@mock.patch('nova.virt.libvirt.utils.fetch_image')
1602316025
@mock.patch('nova.virt.libvirt.utils.create_image')
1602416026
def test_create_ephemeral_specified_fs_not_valid(
16025-
self, mock_create_cow_image, mock_fetch_image, mock_utime):
16027+
self, mock_create_cow_image, mock_fetch_image, mock_utime,
16028+
mock_detect):
1602616029
CONF.set_override('default_ephemeral_format', 'ext4')
1602716030
ephemerals = [{'device_type': 'disk',
1602816031
'disk_bus': 'virtio',

nova/tests/unit/virt/libvirt/test_imagebackend.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -524,13 +524,15 @@ def test_cache_template_exists(self, mock_exists):
524524

525525
mock_exists.assert_has_calls(exist_calls)
526526

527+
@mock.patch('nova.image.format_inspector.detect_file_format')
527528
@mock.patch.object(imagebackend.utils, 'synchronized')
528529
@mock.patch('nova.virt.libvirt.utils.create_image')
529530
@mock.patch.object(os.path, 'exists', side_effect=[])
530531
@mock.patch.object(imagebackend.Image, 'verify_base_size')
531532
@mock.patch('nova.privsep.path.utime')
532533
def test_create_image(
533-
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync
534+
self, mock_utime, mock_verify, mock_exist, mock_create, mock_sync,
535+
mock_detect_format
534536
):
535537
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
536538
fn = mock.MagicMock()
@@ -551,15 +553,19 @@ def test_create_image(
551553
mock_exist.assert_has_calls(exist_calls)
552554
self.assertTrue(mock_sync.called)
553555
mock_utime.assert_called()
556+
mock_detect_format.assert_called_once()
557+
mock_detect_format.return_value.safety_check.assert_called_once_with()
554558

559+
@mock.patch('nova.image.format_inspector.detect_file_format')
555560
@mock.patch.object(imagebackend.utils, 'synchronized')
556561
@mock.patch('nova.virt.libvirt.utils.create_image')
557562
@mock.patch.object(imagebackend.disk, 'extend')
558563
@mock.patch.object(os.path, 'exists', side_effect=[])
559564
@mock.patch.object(imagebackend.Qcow2, 'get_disk_size')
560565
@mock.patch('nova.privsep.path.utime')
561566
def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
562-
mock_extend, mock_create, mock_sync):
567+
mock_extend, mock_create, mock_sync,
568+
mock_detect_format):
563569
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
564570
mock_get.return_value = self.SIZE
565571
fn = mock.MagicMock()
@@ -576,7 +582,9 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
576582
self.assertTrue(mock_sync.called)
577583
self.assertFalse(mock_create.called)
578584
self.assertFalse(mock_extend.called)
585+
mock_detect_format.assert_called_once()
579586

587+
@mock.patch('nova.image.format_inspector.detect_file_format')
580588
@mock.patch.object(imagebackend.utils, 'synchronized')
581589
@mock.patch('nova.virt.libvirt.utils.create_image')
582590
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -588,7 +596,8 @@ def test_create_image_too_small(self, mock_utime, mock_get, mock_exist,
588596
def test_generate_resized_backing_files(self, mock_utime, mock_copy,
589597
mock_verify, mock_exist,
590598
mock_extend, mock_get,
591-
mock_create, mock_sync):
599+
mock_create, mock_sync,
600+
mock_detect_format):
592601
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
593602
mock_get.return_value = self.QCOW2_BASE
594603
fn = mock.MagicMock()
@@ -615,7 +624,9 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
615624
self.assertTrue(mock_sync.called)
616625
self.assertFalse(mock_create.called)
617626
mock_utime.assert_called()
627+
mock_detect_format.assert_called_once()
618628

629+
@mock.patch('nova.image.format_inspector.detect_file_format')
619630
@mock.patch.object(imagebackend.utils, 'synchronized')
620631
@mock.patch('nova.virt.libvirt.utils.create_image')
621632
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
@@ -626,7 +637,8 @@ def test_generate_resized_backing_files(self, mock_utime, mock_copy,
626637
def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
627638
mock_verify, mock_exist,
628639
mock_extend, mock_get,
629-
mock_create, mock_sync):
640+
mock_create, mock_sync,
641+
mock_detect_format):
630642
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
631643
mock_get.return_value = None
632644
fn = mock.MagicMock()
@@ -647,6 +659,31 @@ def test_qcow2_exists_and_has_no_backing_file(self, mock_utime,
647659
self.assertTrue(mock_sync.called)
648660
self.assertFalse(mock_create.called)
649661
self.assertFalse(mock_extend.called)
662+
mock_detect_format.assert_called_once()
663+
664+
@mock.patch('nova.image.format_inspector.detect_file_format')
665+
@mock.patch.object(imagebackend.utils, 'synchronized')
666+
@mock.patch('nova.virt.libvirt.utils.create_image')
667+
@mock.patch('nova.virt.libvirt.utils.get_disk_backing_file')
668+
@mock.patch.object(imagebackend.disk, 'extend')
669+
@mock.patch.object(os.path, 'exists', side_effect=[])
670+
@mock.patch.object(imagebackend.Image, 'verify_base_size')
671+
def test_qcow2_exists_and_fails_safety_check(self,
672+
mock_verify, mock_exist,
673+
mock_extend, mock_get,
674+
mock_create, mock_sync,
675+
mock_detect_format):
676+
mock_detect_format.return_value.safety_check.return_value = False
677+
mock_sync.side_effect = lambda *a, **kw: self._fake_deco
678+
mock_get.return_value = None
679+
fn = mock.MagicMock()
680+
mock_exist.side_effect = [False, True, False, True, True]
681+
image = self.image_class(self.INSTANCE, self.NAME)
682+
683+
self.assertRaises(exception.InvalidDiskInfo,
684+
image.create_image, fn, self.TEMPLATE_PATH,
685+
self.SIZE)
686+
mock_verify.assert_not_called()
650687

651688
def test_resolve_driver_format(self):
652689
image = self.image_class(self.INSTANCE, self.NAME)

nova/tests/unit/virt/libvirt/test_utils.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,27 @@ def test_valid_hostname_bad(self):
106106

107107
@mock.patch('oslo_concurrency.processutils.execute')
108108
@mock.patch('nova.virt.images.qemu_img_info')
109+
@mock.patch('nova.image.format_inspector.detect_file_format')
109110
def _test_create_image(
110-
self, path, disk_format, disk_size, mock_info, mock_execute,
111-
backing_file=None
111+
self, path, disk_format, disk_size, mock_detect, mock_info,
112+
mock_execute, backing_file=None, safety_check=True
112113
):
114+
if isinstance(backing_file, dict):
115+
backing_info = backing_file
116+
backing_file = backing_info.pop('file', None)
117+
else:
118+
backing_info = {}
119+
backing_backing_file = backing_info.pop('backing_file', None)
120+
113121
mock_info.return_value = mock.Mock(
114122
file_format=mock.sentinel.backing_fmt,
115123
cluster_size=mock.sentinel.cluster_size,
124+
backing_file=backing_backing_file,
125+
format_specific=backing_info,
116126
)
117127

128+
mock_detect.return_value.safety_check.return_value = safety_check
129+
118130
libvirt_utils.create_image(
119131
path, disk_format, disk_size, backing_file=backing_file)
120132

@@ -126,7 +138,7 @@ def _test_create_image(
126138
mock_info.assert_called_once_with(backing_file)
127139
cow_opts = [
128140
'-o',
129-
f'backing_file={mock.sentinel.backing_file},'
141+
f'backing_file={backing_file},'
130142
f'backing_fmt={mock.sentinel.backing_fmt},'
131143
f'cluster_size={mock.sentinel.cluster_size}',
132144
]
@@ -139,6 +151,8 @@ def _test_create_image(
139151
expected_args += (disk_size,)
140152

141153
self.assertEqual([(expected_args,)], mock_execute.call_args_list)
154+
if backing_file:
155+
mock_detect.return_value.safety_check.assert_called_once_with()
142156

143157
def test_create_image_raw(self):
144158
self._test_create_image('/some/path', 'raw', '10G')
@@ -154,6 +168,25 @@ def test_create_image_backing_file(self):
154168
backing_file=mock.sentinel.backing_file,
155169
)
156170

171+
def test_create_image_base_has_backing_file(self):
172+
self.assertRaises(
173+
exception.InvalidDiskInfo,
174+
self._test_create_image,
175+
'/some/stuff', 'qcow2', '1234567891234',
176+
backing_file={'file': mock.sentinel.backing_file,
177+
'backing_file': mock.sentinel.backing_backing_file},
178+
)
179+
180+
def test_create_image_base_has_data_file(self):
181+
self.assertRaises(
182+
exception.InvalidDiskInfo,
183+
self._test_create_image,
184+
'/some/stuff', 'qcow2', '1234567891234',
185+
backing_file={'file': mock.sentinel.backing_file,
186+
'backing_file': mock.sentinel.backing_backing_file,
187+
'data': {'data-file': mock.sentinel.data_file}},
188+
)
189+
157190
def test_create_image_size_none(self):
158191
self._test_create_image(
159192
'/some/stuff', 'qcow2', None,

nova/virt/libvirt/imagebackend.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import nova.conf
3535
from nova import exception
3636
from nova.i18n import _
37+
from nova.image import format_inspector
3738
from nova.image import glance
3839
import nova.privsep.libvirt
3940
import nova.privsep.path
@@ -660,6 +661,20 @@ def create_qcow2_image(base, target, size):
660661
if not os.path.exists(base):
661662
prepare_template(target=base, *args, **kwargs)
662663

664+
# NOTE(danms): We need to perform safety checks on the base image
665+
# before we inspect it for other attributes. We do this each time
666+
# because additional safety checks could have been added since we
667+
# downloaded the image.
668+
if not CONF.workarounds.disable_deep_image_inspection:
669+
inspector = format_inspector.detect_file_format(base)
670+
if not inspector.safety_check():
671+
LOG.warning('Base image %s failed safety check', base)
672+
# NOTE(danms): This is the same exception as would be raised
673+
# by qemu_img_info() if the disk format was unreadable or
674+
# otherwise unsuitable.
675+
raise exception.InvalidDiskInfo(
676+
reason=_('Base image failed safety check'))
677+
663678
# NOTE(ankit): Update the mtime of the base file so the image
664679
# cache manager knows it is in use.
665680
_update_utime_ignore_eacces(base)

nova/virt/libvirt/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from nova import context as nova_context
3535
from nova import exception
3636
from nova.i18n import _
37+
from nova.image import format_inspector
3738
from nova import objects
3839
from nova.objects import fields as obj_fields
3940
import nova.privsep.fs
@@ -132,7 +133,34 @@ def create_image(
132133
cow_opts = []
133134

134135
if backing_file:
136+
# NOTE(danms): We need to perform safety checks on the base image
137+
# before we inspect it for other attributes. We do this each time
138+
# because additional safety checks could have been added since we
139+
# downloaded the image.
140+
if not CONF.workarounds.disable_deep_image_inspection:
141+
inspector = format_inspector.detect_file_format(backing_file)
142+
if not inspector.safety_check():
143+
LOG.warning('Base image %s failed safety check', backing_file)
144+
# NOTE(danms): This is the same exception as would be raised
145+
# by qemu_img_info() if the disk format was unreadable or
146+
# otherwise unsuitable.
147+
raise exception.InvalidDiskInfo(
148+
reason=_('Base image failed safety check'))
149+
135150
base_details = images.qemu_img_info(backing_file)
151+
if base_details.backing_file is not None:
152+
LOG.warning('Base image %s failed safety check', backing_file)
153+
raise exception.InvalidDiskInfo(
154+
reason=_('Base image failed safety check'))
155+
try:
156+
data_file = base_details.format_specific['data']['data-file']
157+
except (KeyError, TypeError, AttributeError):
158+
data_file = None
159+
if data_file is not None:
160+
LOG.warning('Base image %s failed safety check', backing_file)
161+
raise exception.InvalidDiskInfo(
162+
reason=_('Base image failed safety check'))
163+
136164
cow_opts += [
137165
f'backing_file={backing_file}',
138166
f'backing_fmt={base_details.file_format}'

0 commit comments

Comments
 (0)