Skip to content

Commit 08e8794

Browse files
committed
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: QubesOS/qubes-issues#899
1 parent 1473c37 commit 08e8794

File tree

7 files changed

+151
-0
lines changed

7 files changed

+151
-0
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ ADMIN_API_METHODS_SIMPLE = \
109109
admin.vm.firewall.GetPolicy \
110110
admin.vm.firewall.SetPolicy \
111111
admin.vm.firewall.Reload \
112+
admin.vm.notes.Get \
113+
admin.vm.notes.Set \
112114
admin.vm.property.Get \
113115
admin.vm.property.GetAll \
114116
admin.vm.property.GetDefault \

qubes/api/admin.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import subprocess
2929
import pathlib
3030

31+
from ctypes import CDLL
32+
3133
import libvirt
3234
import lxml.etree
3335
import importlib.metadata
@@ -53,6 +55,9 @@
5355
DeviceInterface,
5456
)
5557

58+
# To validate & sanitise UTF8 strings
59+
LIBQUBES_PURE = "libqubes-pure.so.0"
60+
5661

5762
class QubesMgmtEventsDispatcher:
5863
def __init__(self, filters, send_event):
@@ -2036,3 +2041,48 @@ async def vm_current_state(self):
20362041
"power_state": self.dest.get_power_state(),
20372042
}
20382043
return " ".join("{}={}".format(k, v) for k, v in state.items())
2044+
2045+
@qubes.api.method(
2046+
"admin.vm.notes.Get", no_payload=True, scope="local", read=True
2047+
)
2048+
async def vm_notes_get(self):
2049+
"""Get qube notes"""
2050+
self.enforce(self.dest.name != "dom0")
2051+
self.fire_event_for_permission()
2052+
notes = self.dest.get_notes()
2053+
return notes
2054+
2055+
@qubes.api.method("admin.vm.notes.Set", scope="local", write=True)
2056+
async def vm_notes_set(self, untrusted_payload):
2057+
"""Set qube notes"""
2058+
self.enforce(self.dest.name != "dom0")
2059+
self.fire_event_for_permission()
2060+
if len(untrusted_payload) > 256000:
2061+
raise qubes.exc.ProtocolError(
2062+
"Maximum note size is 256000 bytes ({} bytes received)".format(
2063+
len(untrusted_payload)
2064+
)
2065+
)
2066+
2067+
# Sanitise the incoming utf8 notes with libqubes-pure
2068+
# use encode() to catch invalid UTF8 chars even earlier
2069+
try:
2070+
libqubespure = CDLL(LIBQUBES_PURE)
2071+
notes = "".join(
2072+
[
2073+
(
2074+
c
2075+
if libqubespure.qubes_pure_string_safe_for_display(
2076+
f"{c}\0".encode(), 0
2077+
)
2078+
else "_"
2079+
)
2080+
for c in untrusted_payload.decode("utf8")
2081+
]
2082+
)
2083+
except Exception as e:
2084+
raise qubes.exc.ProtocolError(
2085+
"Unable to sanitise qube notes: " + str(e)
2086+
)
2087+
2088+
self.dest.set_notes(notes)

qubes/backup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ def get_files_to_backup(self):
433433
if os.path.exists(firewall_conf):
434434
vm_files.append(self.FileToBackup(firewall_conf, subdir))
435435

436+
notes_file_path = os.path.join(vm.dir_path, vm.notes_file)
437+
if os.path.exists(notes_file_path):
438+
vm_files.append(self.FileToBackup(notes_file_path, subdir))
439+
436440
if not vm_files:
437441
# subdir/ is needed in the tar file, otherwise restore
438442
# of a (Disp)VM without any backed up files is going

qubes/tests/api_admin.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,44 @@ def test_450_property_reset(self):
21302130
self.assertIsNone(value)
21312131
self.app.save.assert_called_once_with()
21322132

2133+
def test_notes_get(self):
2134+
notes = "For Your Eyes Only"
2135+
self.app.domains["test-vm1"].get_notes = unittest.mock.Mock()
2136+
self.app.domains["test-vm1"].get_notes.configure_mock(
2137+
**{"return_value": notes}
2138+
)
2139+
value = self.call_mgmt_func(b"admin.vm.notes.Get", b"test-vm1")
2140+
self.assertEqual(value, notes)
2141+
self.app.domains["test-vm1"].get_notes.configure_mock(
2142+
**{"side_effect": qubes.exc.QubesException()}
2143+
)
2144+
with self.assertRaises(qubes.exc.QubesException):
2145+
self.call_mgmt_func(b"admin.vm.notes.Get", b"test-vm1")
2146+
self.assertEqual(
2147+
self.app.domains["test-vm1"].get_notes.mock_calls,
2148+
[unittest.mock.call(), unittest.mock.call()],
2149+
)
2150+
self.assertFalse(self.app.save.called)
2151+
2152+
def test_notes_set(self):
2153+
self.app.domains["test-vm1"].set_notes = unittest.mock.Mock()
2154+
# Acceptable note
2155+
payload = "For Your Eyes Only".encode()
2156+
self.call_mgmt_func(
2157+
b"admin.vm.notes.Set",
2158+
b"test-vm1",
2159+
payload=payload,
2160+
)
2161+
2162+
# Unacceptable oversized note
2163+
with self.assertRaises(qubes.exc.ProtocolError):
2164+
payload = ("x" * 256001).encode()
2165+
self.call_mgmt_func(
2166+
b"admin.vm.notes.Set",
2167+
b"test-vm1",
2168+
payload=payload,
2169+
)
2170+
21332171
def device_list_testclass(self, vm, event):
21342172
if vm is not self.vm:
21352173
return

qubes/tests/vm/qubesvm.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,6 +3096,21 @@ def test_801_ordering(self):
30963096
self.app, None, qid=1, name="bogus"
30973097
) > qubes.vm.adminvm.AdminVM(self.app, None)
30983098

3099+
def test_802_notes(self):
3100+
vm = self.get_vm()
3101+
notes = "For Your Eyes Only"
3102+
with unittest.mock.patch(
3103+
"builtins.open", unittest.mock.mock_open(read_data=notes)
3104+
) as mock_open:
3105+
with self.assertNotRaises(qubes.exc.QubesException):
3106+
vm.set_notes(notes)
3107+
self.assertEqual(vm.get_notes(), notes)
3108+
mock_open.side_effect = FileNotFoundError()
3109+
self.assertEqual(vm.get_notes(), "")
3110+
with self.assertRaises(qubes.exc.QubesException):
3111+
mock_open.side_effect = PermissionError()
3112+
vm.get_notes()
3113+
30993114
def test_810_bootmode_kernelopts(self):
31003115
vm = self.get_vm(cls=qubes.vm.appvm.AppVM)
31013116
vm.template = self.get_vm(cls=qubes.vm.templatevm.TemplateVM)

qubes/vm/qubesvm.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2677,6 +2677,43 @@ def kernelopts_common(self):
26772677

26782678
return result
26792679

2680+
#
2681+
# free-form text for descriptions, notes, comments, remarks, etc.
2682+
#
2683+
2684+
@property
2685+
def notes_file(self) -> str:
2686+
"""Notes file name within /var/lib/qubes (per each qube sub-dir)"""
2687+
return "notes.txt"
2688+
2689+
def get_notes(self) -> str:
2690+
"""Read the notes file and return its content"""
2691+
try:
2692+
with open(
2693+
os.path.join(self.dir_path, self.notes_file), encoding="utf8"
2694+
) as fd:
2695+
return fd.read()
2696+
except FileNotFoundError:
2697+
return ""
2698+
except Exception as exc:
2699+
raise qubes.exc.QubesException(
2700+
"Failed to read notes file: " + str(exc)
2701+
)
2702+
2703+
def set_notes(self, notes: str):
2704+
"""Write to notes file. Return True on success, False on error"""
2705+
try:
2706+
with open(
2707+
os.path.join(self.dir_path, self.notes_file),
2708+
"w",
2709+
encoding="utf8",
2710+
) as fd:
2711+
fd.write(notes)
2712+
except Exception as exc:
2713+
raise qubes.exc.QubesException(
2714+
"Failed to write notes file: " + str(exc)
2715+
)
2716+
26802717
#
26812718
# helper methods
26822719
#

rpm_spec/core-dom0.spec.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ Conflicts: qubes-audio-dom0 < 4.3.5
108108
# Required for qvm-console* tools
109109
Requires: socat
110110

111+
# Requires libqubes-pure for qube notes utf8 sanitisation
112+
Requires: qubes-utils-libs
113+
111114
%{?systemd_requires}
112115

113116
Obsoletes: qubes-core-dom0-doc <= 4.0
@@ -286,6 +289,8 @@ admin.vm.feature.Set
286289
admin.vm.firewall.Get
287290
admin.vm.firewall.Reload
288291
admin.vm.firewall.Set
292+
admin.vm.notes.Get
293+
admin.vm.notes.Set
289294
admin.vm.property.Get
290295
admin.vm.property.GetAll
291296
admin.vm.property.GetDefault

0 commit comments

Comments
 (0)