From 96cc2acbd1f5f64c91f31860f9745f14c032bf0d Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Fri, 14 Feb 2025 22:08:02 +0330 Subject: [PATCH] Add free-form text to qube for notes, comments, ... Core and API part of adding free-form text to each qube for comments, notes, descriptions, remarks, reminders, etc. fixes: https://github.com/QubesOS/qubes-issues/issues/899 --- .gitlab-ci.yml | 4 +++ Makefile | 2 ++ qubes/api/admin.py | 52 ++++++++++++++++++++++++++++ qubes/backup.py | 4 +++ qubes/tests/api_admin.py | 71 ++++++++++++++++++++++++++++++++++++++ qubes/tests/vm/qubesvm.py | 18 ++++++++++ qubes/vm/qubesvm.py | 37 ++++++++++++++++++++ rpm_spec/core-dom0.spec.in | 5 +++ 8 files changed, 193 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2fbe0ce90..28ce15d1d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -33,6 +33,10 @@ checks:tests: # itself unless there's memory pressure, and the system will fail to request # memory from qmemman since qmemman will not see enough memory to run. sudo modprobe zfs zfs_arc_max=67108864 + - git clone https://github.com/QubesOS/qubes-linux-utils ~/qubes-linux-utils + # the below 2 lines work like a chisel and hammer in a caveman's hand :/ + - make -C ~/qubes-linux-utils/qrexec-lib NO_REBUILD_TABLE=1 + - sudo install ~/qubes-linux-utils/qrexec-lib/libqubes-pure.so.0 /usr/lib64 script: - PYTHONPATH=test-packages:~/qubes-core-qrexec ./run-tests stage: checks diff --git a/Makefile b/Makefile index 852186ff4..ceefb386e 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,8 @@ ADMIN_API_METHODS_SIMPLE = \ admin.vm.firewall.GetPolicy \ admin.vm.firewall.SetPolicy \ admin.vm.firewall.Reload \ + admin.vm.notes.Get \ + admin.vm.notes.Set \ admin.vm.property.Get \ admin.vm.property.GetAll \ admin.vm.property.GetDefault \ diff --git a/qubes/api/admin.py b/qubes/api/admin.py index 4dcfc776b..353c958f8 100644 --- a/qubes/api/admin.py +++ b/qubes/api/admin.py @@ -28,6 +28,8 @@ import subprocess import pathlib +from ctypes import CDLL + import libvirt import lxml.etree import importlib.metadata @@ -53,6 +55,9 @@ DeviceInterface, ) +# To validate & sanitise UTF8 strings +LIBQUBES_PURE = "libqubes-pure.so.0" + class QubesMgmtEventsDispatcher: def __init__(self, filters, send_event): @@ -2036,3 +2041,50 @@ async def vm_current_state(self): "power_state": self.dest.get_power_state(), } return " ".join("{}={}".format(k, v) for k, v in state.items()) + + @qubes.api.method( + "admin.vm.notes.Get", no_payload=True, scope="local", read=True + ) + async def vm_notes_get(self): + """Get qube notes""" + self.enforce(self.dest.name != "dom0") + self.fire_event_for_permission() + notes = self.dest.get_notes() + return notes + + @qubes.api.method("admin.vm.notes.Set", scope="local", write=True) + async def vm_notes_set(self, untrusted_payload): + """Set qube notes""" + self.enforce(self.dest.name != "dom0") + self.fire_event_for_permission() + if len(untrusted_payload) > 256000: + raise qubes.exc.ProtocolError( + "Maximum note size is 256000 bytes ({} bytes received)".format( + len(untrusted_payload) + ) + ) + + # Sanitise the incoming utf8 notes with libqubes-pure + try: + libqubespure = CDLL(LIBQUBES_PURE) + notes = "".join( + [ + ( + c + # first we check with our advanced unicode sanitisation + if libqubespure.qubes_pure_code_point_safe_for_display( + ord(c) + ) + # validate tab and newline since qubespure excludes them + or c in "\t\n" + else "_" + ) + for c in untrusted_payload.decode("utf8") + ] + ) + except Exception as e: + raise qubes.exc.ProtocolError( + "Unable to sanitise qube notes: " + str(e) + ) + + self.dest.set_notes(notes) diff --git a/qubes/backup.py b/qubes/backup.py index aca11673e..c92f7f58a 100644 --- a/qubes/backup.py +++ b/qubes/backup.py @@ -433,6 +433,10 @@ def get_files_to_backup(self): if os.path.exists(firewall_conf): vm_files.append(self.FileToBackup(firewall_conf, subdir)) + notes_file_path = os.path.join(vm.dir_path, vm.notes_file) + if os.path.exists(notes_file_path): + vm_files.append(self.FileToBackup(notes_file_path, subdir)) + if not vm_files: # subdir/ is needed in the tar file, otherwise restore # of a (Disp)VM without any backed up files is going diff --git a/qubes/tests/api_admin.py b/qubes/tests/api_admin.py index a992a226c..27d2eb018 100644 --- a/qubes/tests/api_admin.py +++ b/qubes/tests/api_admin.py @@ -2130,6 +2130,77 @@ def test_450_property_reset(self): self.assertIsNone(value) self.app.save.assert_called_once_with() + def test_notes_get(self): + notes = "For Your Eyes Only" + self.app.domains["test-vm1"].get_notes = unittest.mock.Mock() + self.app.domains["test-vm1"].get_notes.configure_mock( + **{"return_value": notes} + ) + value = self.call_mgmt_func(b"admin.vm.notes.Get", b"test-vm1") + self.assertEqual(value, notes) + self.app.domains["test-vm1"].get_notes.configure_mock( + **{"side_effect": qubes.exc.QubesException()} + ) + with self.assertRaises(qubes.exc.QubesException): + self.call_mgmt_func(b"admin.vm.notes.Get", b"test-vm1") + self.assertEqual( + self.app.domains["test-vm1"].get_notes.mock_calls, + [unittest.mock.call(), unittest.mock.call()], + ) + self.assertFalse(self.app.save.called) + + def test_notes_set(self): + self.app.domains["test-vm1"].set_notes = unittest.mock.Mock() + + # Acceptable note + payload = b"For Your Eyes Only" + self.call_mgmt_func( + b"admin.vm.notes.Set", + b"test-vm1", + payload=payload, + ) + self.app.domains["test-vm1"].set_notes.assert_called_with( + payload.decode() + ) + + # Note with new-line & tab characters + payload = b"def python_example_function():\n\tpass" + self.call_mgmt_func( + b"admin.vm.notes.Set", + b"test-vm1", + payload=payload, + ) + self.app.domains["test-vm1"].set_notes.assert_called_with( + payload.decode() + ) + + # Note with un-acceptable character (backspace, non-breaking space, ...) + payload = "\b\xa0\u200b\u200c\u200d".encode() + self.call_mgmt_func( + b"admin.vm.notes.Set", + b"test-vm1", + payload=payload, + ) + self.app.domains["test-vm1"].set_notes.assert_called_with("_____") + + # Invalid UTF8 sequence + with self.assertRaises(qubes.exc.ProtocolError): + payload = b"\xd8" + self.call_mgmt_func( + b"admin.vm.notes.Set", + b"test-vm1", + payload=payload, + ) + + # Unacceptable oversized note + with self.assertRaises(qubes.exc.ProtocolError): + payload = ("x" * 256001).encode() + self.call_mgmt_func( + b"admin.vm.notes.Set", + b"test-vm1", + payload=payload, + ) + def device_list_testclass(self, vm, event): if vm is not self.vm: return diff --git a/qubes/tests/vm/qubesvm.py b/qubes/tests/vm/qubesvm.py index 09bc4f36f..d59cae66d 100644 --- a/qubes/tests/vm/qubesvm.py +++ b/qubes/tests/vm/qubesvm.py @@ -3096,6 +3096,24 @@ def test_801_ordering(self): self.app, None, qid=1, name="bogus" ) > qubes.vm.adminvm.AdminVM(self.app, None) + def test_802_notes(self): + vm = self.get_vm() + notes = "For Your Eyes Only" + with unittest.mock.patch( + "builtins.open", unittest.mock.mock_open(read_data=notes) + ) as mock_open: + with self.assertNotRaises(qubes.exc.QubesException): + vm.set_notes(notes) + self.assertEqual(vm.get_notes(), notes) + mock_open.side_effect = FileNotFoundError() + self.assertEqual(vm.get_notes(), "") + with self.assertRaises(qubes.exc.QubesException): + mock_open.side_effect = PermissionError() + vm.set_notes(notes) + with self.assertRaises(qubes.exc.QubesException): + mock_open.side_effect = PermissionError() + vm.get_notes() + def test_810_bootmode_kernelopts(self): vm = self.get_vm(cls=qubes.vm.appvm.AppVM) vm.template = self.get_vm(cls=qubes.vm.templatevm.TemplateVM) diff --git a/qubes/vm/qubesvm.py b/qubes/vm/qubesvm.py index 58c4cd813..b10a256b5 100644 --- a/qubes/vm/qubesvm.py +++ b/qubes/vm/qubesvm.py @@ -2677,6 +2677,43 @@ def kernelopts_common(self): return result + # + # free-form text for descriptions, notes, comments, remarks, etc. + # + + @property + def notes_file(self) -> str: + """Notes file name within /var/lib/qubes (per each qube sub-dir)""" + return "notes.txt" + + def get_notes(self) -> str: + """Read the notes file and return its content""" + try: + with open( + os.path.join(self.dir_path, self.notes_file), encoding="utf8" + ) as fd: + return fd.read() + except FileNotFoundError: + return "" + except Exception as exc: + raise qubes.exc.QubesException( + "Failed to read notes file: " + str(exc) + ) + + def set_notes(self, notes: str): + """Write to notes file. Return True on success, False on error""" + try: + with open( + os.path.join(self.dir_path, self.notes_file), + "w", + encoding="utf8", + ) as fd: + fd.write(notes) + except Exception as exc: + raise qubes.exc.QubesException( + "Failed to write notes file: " + str(exc) + ) + # # helper methods # diff --git a/rpm_spec/core-dom0.spec.in b/rpm_spec/core-dom0.spec.in index 6453f00be..685abeb6f 100644 --- a/rpm_spec/core-dom0.spec.in +++ b/rpm_spec/core-dom0.spec.in @@ -108,6 +108,9 @@ Conflicts: qubes-audio-dom0 < 4.3.5 # Required for qvm-console* tools Requires: socat +# Requires libqubes-pure for qube notes utf8 sanitisation +Requires: qubes-utils-libs + %{?systemd_requires} Obsoletes: qubes-core-dom0-doc <= 4.0 @@ -286,6 +289,8 @@ admin.vm.feature.Set admin.vm.firewall.Get admin.vm.firewall.Reload admin.vm.firewall.Set +admin.vm.notes.Get +admin.vm.notes.Set admin.vm.property.Get admin.vm.property.GetAll admin.vm.property.GetDefault