Skip to content

Commit 96cc2ac

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 96cc2ac

File tree

8 files changed

+193
-0
lines changed

8 files changed

+193
-0
lines changed

.gitlab-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ checks:tests:
3333
# itself unless there's memory pressure, and the system will fail to request
3434
# memory from qmemman since qmemman will not see enough memory to run.
3535
sudo modprobe zfs zfs_arc_max=67108864
36+
- git clone https://github.com/QubesOS/qubes-linux-utils ~/qubes-linux-utils
37+
# the below 2 lines work like a chisel and hammer in a caveman's hand :/
38+
- make -C ~/qubes-linux-utils/qrexec-lib NO_REBUILD_TABLE=1
39+
- sudo install ~/qubes-linux-utils/qrexec-lib/libqubes-pure.so.0 /usr/lib64
3640
script:
3741
- PYTHONPATH=test-packages:~/qubes-core-qrexec ./run-tests
3842
stage: checks

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: 52 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,50 @@ 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+
try:
2069+
libqubespure = CDLL(LIBQUBES_PURE)
2070+
notes = "".join(
2071+
[
2072+
(
2073+
c
2074+
# first we check with our advanced unicode sanitisation
2075+
if libqubespure.qubes_pure_code_point_safe_for_display(
2076+
ord(c)
2077+
)
2078+
# validate tab and newline since qubespure excludes them
2079+
or c in "\t\n"
2080+
else "_"
2081+
)
2082+
for c in untrusted_payload.decode("utf8")
2083+
]
2084+
)
2085+
except Exception as e:
2086+
raise qubes.exc.ProtocolError(
2087+
"Unable to sanitise qube notes: " + str(e)
2088+
)
2089+
2090+
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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,77 @@ 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+
2155+
# Acceptable note
2156+
payload = b"For Your Eyes Only"
2157+
self.call_mgmt_func(
2158+
b"admin.vm.notes.Set",
2159+
b"test-vm1",
2160+
payload=payload,
2161+
)
2162+
self.app.domains["test-vm1"].set_notes.assert_called_with(
2163+
payload.decode()
2164+
)
2165+
2166+
# Note with new-line & tab characters
2167+
payload = b"def python_example_function():\n\tpass"
2168+
self.call_mgmt_func(
2169+
b"admin.vm.notes.Set",
2170+
b"test-vm1",
2171+
payload=payload,
2172+
)
2173+
self.app.domains["test-vm1"].set_notes.assert_called_with(
2174+
payload.decode()
2175+
)
2176+
2177+
# Note with un-acceptable character (backspace, non-breaking space, ...)
2178+
payload = "\b\xa0\u200b\u200c\u200d".encode()
2179+
self.call_mgmt_func(
2180+
b"admin.vm.notes.Set",
2181+
b"test-vm1",
2182+
payload=payload,
2183+
)
2184+
self.app.domains["test-vm1"].set_notes.assert_called_with("_____")
2185+
2186+
# Invalid UTF8 sequence
2187+
with self.assertRaises(qubes.exc.ProtocolError):
2188+
payload = b"\xd8"
2189+
self.call_mgmt_func(
2190+
b"admin.vm.notes.Set",
2191+
b"test-vm1",
2192+
payload=payload,
2193+
)
2194+
2195+
# Unacceptable oversized note
2196+
with self.assertRaises(qubes.exc.ProtocolError):
2197+
payload = ("x" * 256001).encode()
2198+
self.call_mgmt_func(
2199+
b"admin.vm.notes.Set",
2200+
b"test-vm1",
2201+
payload=payload,
2202+
)
2203+
21332204
def device_list_testclass(self, vm, event):
21342205
if vm is not self.vm:
21352206
return

qubes/tests/vm/qubesvm.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,6 +3096,24 @@ 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.set_notes(notes)
3113+
with self.assertRaises(qubes.exc.QubesException):
3114+
mock_open.side_effect = PermissionError()
3115+
vm.get_notes()
3116+
30993117
def test_810_bootmode_kernelopts(self):
31003118
vm = self.get_vm(cls=qubes.vm.appvm.AppVM)
31013119
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)