diff --git a/mounts/org.osbuild.btrfs b/mounts/org.osbuild.btrfs index cc8bba66..950426f3 100755 --- a/mounts/org.osbuild.btrfs +++ b/mounts/org.osbuild.btrfs @@ -26,7 +26,13 @@ SCHEMA_2 = """ }, "options": { "type": "object", - "additionalProperties": true + "additionalProperties": false, + "properties": { + "readonly": { + "description": "mount the source as a readonly device", + "type": "boolean" + } + } } } """ @@ -34,8 +40,8 @@ SCHEMA_2 = """ class BtrfsMount(mounts.FileSystemMountService): - def translate_options(self, _options: Dict): - return ["-t", "btrfs"] + def translate_options(self, options: Dict): + return ["-t", "btrfs"] + super().translate_options(options) def main(): diff --git a/mounts/org.osbuild.ext4 b/mounts/org.osbuild.ext4 index 5261c9b4..23b224f7 100755 --- a/mounts/org.osbuild.ext4 +++ b/mounts/org.osbuild.ext4 @@ -26,7 +26,13 @@ SCHEMA_2 = """ }, "options": { "type": "object", - "additionalProperties": true + "additionalProperties": false, + "properties": { + "readonly": { + "description": "mount the source as a readonly device", + "type": "boolean" + } + } } } """ @@ -34,8 +40,8 @@ SCHEMA_2 = """ class Ext4Mount(mounts.FileSystemMountService): - def translate_options(self, _options: Dict): - return ["-t", "ext4"] + def translate_options(self, options: Dict): + return ["-t", "ext4"] + super().translate_options(options) def main(): diff --git a/mounts/org.osbuild.fat b/mounts/org.osbuild.fat index 197ae030..6d89fd7e 100755 --- a/mounts/org.osbuild.fat +++ b/mounts/org.osbuild.fat @@ -26,7 +26,13 @@ SCHEMA_2 = """ }, "options": { "type": "object", - "additionalProperties": true + "additionalProperties": false, + "properties": { + "readonly": { + "description": "mount the source as a readonly device", + "type": "boolean" + } + } } } """ @@ -34,8 +40,8 @@ SCHEMA_2 = """ class FatMount(mounts.FileSystemMountService): - def translate_options(self, _options: Dict): - return ["-t", "vfat"] + def translate_options(self, options: Dict): + return ["-t", "vfat"] + super().translate_options(options) def main(): diff --git a/mounts/org.osbuild.xfs b/mounts/org.osbuild.xfs index 9c807591..1f33d237 100755 --- a/mounts/org.osbuild.xfs +++ b/mounts/org.osbuild.xfs @@ -26,7 +26,13 @@ SCHEMA_2 = """ }, "options": { "type": "object", - "additionalProperties": true + "additionalProperties": false, + "properties": { + "readonly": { + "description": "mount the source as a readonly device", + "type": "boolean" + } + } } } """ @@ -34,8 +40,8 @@ SCHEMA_2 = """ class XfsMount(mounts.FileSystemMountService): - def translate_options(self, _options: Dict): - return ["-t", "xfs"] + def translate_options(self, options: Dict): + return ["-t", "xfs"] + super().translate_options(options) def main(): diff --git a/osbuild/mounts.py b/osbuild/mounts.py index f3374b75..99b09af1 100644 --- a/osbuild/mounts.py +++ b/osbuild/mounts.py @@ -123,8 +123,11 @@ class FileSystemMountService(MountService): self.mountpoint = None self.check = False + # pylint: disable=no-self-use @abc.abstractmethod def translate_options(self, options: Dict) -> List: + if options.get("readonly", False): + return ["-o", "ro"] return [] def mount(self, args: Dict): diff --git a/test/run/test_mount.py b/test/run/test_mount.py new file mode 100755 index 00000000..e8364ec8 --- /dev/null +++ b/test/run/test_mount.py @@ -0,0 +1,122 @@ +#!/usr/bin/python3 + +# +# Runtime Tests for Device Host Services +# + +import errno +import json +import os +import subprocess +import sys +import tempfile +from contextlib import contextmanager + +import pytest + +from osbuild import devices, host, meta, mounts + +from ..test import TestBase + + +@pytest.fixture(name="tmpdir") +def tmpdir_fixture(): + with tempfile.TemporaryDirectory(prefix="test-devices-") as tmp: + yield tmp + + +@contextmanager +def make_arguments(opts): + os.mkdir("/run/osbuild/api") + with open("/run/osbuild/api/arguments", "w", encoding="utf-8") as f: + json.dump(opts, f) + try: + yield + finally: + os.remove("/run/osbuild/api/arguments") + os.rmdir("/run/osbuild/api") + + +@contextmanager +def make_dev_tmpfs(tmpdir): + dev_path = os.path.join(tmpdir, "dev") + os.makedirs(dev_path) + subprocess.run(["mount", "-t", "tmpfs", "-o", "nosuid", "none", dev_path], check=True) + yield dev_path + subprocess.run(["umount", "--lazy", dev_path], check=True) + + +@pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only") +def test_mount_ro(tmpdir): + index = meta.Index(os.curdir) + + # create a file to contain an image + tree = os.path.join(tmpdir, "tree") + os.makedirs(tree) + size = 1024 * 1024 + file = os.path.join(tree, "image.img") + with open(file, "wb") as f: + f.truncate(size) + + # Create an FS in the image + mkfsopts = { + "devices": { + "device": { + "path": file + } + }, + "options": { + "label": "TEST", + "uuid": "FEEDFACE-CAFE-4004-FEED-C0DEC0FFEE11" + } + } + with make_arguments(mkfsopts): + subprocess.run( + [os.path.join(os.curdir, "stages", "org.osbuild.mkfs.ext4")], + check=True, + stdout=sys.stdout, + stderr=sys.stderr) + + with tempfile.TemporaryDirectory() as mountpoint: + with host.ServiceManager() as mgr: + with make_dev_tmpfs(tmpdir) as devpath: + # Device manager to open the loopback device + devmgr = devices.DeviceManager(mgr, devpath, tree) + # get a Device for the loopback + dev = devices.Device( + "loop", + index.get_module_info( + "Device", + "org.osbuild.loopback" + ), + None, + { + "filename": "image.img", + "start": 0, + "size": size // 512 # size is in sectors / blocks + } + ) + # open the device and get its loopback path + lpath = os.path.join( + devpath, + devmgr.open(dev)["path"] + ) + # mount the loopback + mounts.MountManager( + devmgr, + mountpoint + ).mount( + mounts.Mount( + lpath, + index.get_module_info("Mount", "org.osbuild.ext4"), + dev, + "/", + {"readonly": True} + ) + ) + + # try to write in it, and failure to do so is a success + with pytest.raises(OSError) as err: + with open(os.path.join(mountpoint, "test"), "w", encoding="utf-8") as f: + f.write("should not work") + assert err.value.errno == errno.EROFS # Check that FS is read only