Skip to content

Commit b2041b8

Browse files
committed
Merge remote-tracking branch 'origin/pr/650'
* origin/pr/650: custom-persist: add support of metadata add custom-persist unit tests add custom_persist extension Pull request description: ``custom-persist`` extension (see QubesOS/qubes-issues#1006) Adds a custom-persist feature. The service custom-persist must be activated to enable the feature. When the feature is enabled, user custom persistent file and dirs must be configured with ``qvm-features``. Example: ``qvm-features work custom-persist.var_test /var/test``
2 parents 9b06c22 + 78809ab commit b2041b8

File tree

5 files changed

+353
-0
lines changed

5 files changed

+353
-0
lines changed

doc/qubes-ext.rst

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Extensions defined here
1414
.. autoclass:: qubes.ext.admin.AdminExtension
1515
.. autoclass:: qubes.ext.block.BlockDeviceExtension
1616
.. autoclass:: qubes.ext.core_features.CoreFeatures
17+
.. autoclass:: qubes.ext.custom_persist.CustomPersist
1718
.. autoclass:: qubes.ext.gui.GUI
1819
.. autoclass:: qubes.ext.pci.PCIDeviceExtension
1920
.. autoclass:: qubes.ext.r3compatibility.R3Compatibility

qubes/ext/custom_persist.py

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# -*- encoding: utf-8 -*-
2+
#
3+
# The Qubes OS Project, http://www.qubes-os.org
4+
#
5+
# Copyright (C) 2024 Guillaume Chinal <[email protected]>
6+
#
7+
# This library is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU Lesser General Public
9+
# License as published by the Free Software Foundation; either
10+
# version 2.1 of the License, or (at your option) any later version.
11+
#
12+
# This library is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15+
# Lesser General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU Lesser General Public
18+
# License along with this library; if not, see <https://www.gnu.org/licenses/>.
19+
20+
import os
21+
import qubes.ext
22+
import qubes.config
23+
24+
FEATURE_PREFIX = "custom-persist."
25+
QDB_PREFIX = "/persist/"
26+
QDB_KEY_LIMIT = 63
27+
28+
29+
class CustomPersist(qubes.ext.Extension):
30+
"""This extension allows to create minimal-state APP with by configuring an
31+
exhaustive list of bind dirs(and files)
32+
"""
33+
34+
@staticmethod
35+
def _extract_key_from_feature(feature) -> str:
36+
return feature[len(FEATURE_PREFIX) :]
37+
38+
@staticmethod
39+
def _is_expected_feature(feature) -> bool:
40+
return feature.startswith(FEATURE_PREFIX)
41+
42+
@staticmethod
43+
def _check_key(key):
44+
if not key:
45+
raise qubes.exc.QubesValueError(
46+
"custom-persist key cannot be empty"
47+
)
48+
49+
# QubesDB key length limit
50+
key_maxlen = QDB_KEY_LIMIT - len(QDB_PREFIX)
51+
if len(key) > key_maxlen:
52+
raise qubes.exc.QubesValueError(
53+
"custom-persist key is too long (max {}), ignoring: "
54+
"{}".format(key_maxlen, key)
55+
)
56+
57+
@staticmethod
58+
def _check_value_path(value):
59+
if not os.path.isabs(value):
60+
raise qubes.exc.QubesValueError(f"invalid path '{value}'")
61+
62+
def _check_value(self, value):
63+
if value.startswith("/"):
64+
self._check_value_path(value)
65+
else:
66+
options = value.split(":")
67+
if len(options) < 5 or not options[4].startswith("/"):
68+
raise qubes.exc.QubesValueError(
69+
f"invalid value format: '{value}'"
70+
)
71+
72+
resource_type = options[0]
73+
mode = options[3]
74+
if resource_type not in ("file", "dir"):
75+
raise qubes.exc.QubesValueError(
76+
f"invalid resource type option '{resource_type}' "
77+
f"in value '{value}'"
78+
)
79+
try:
80+
if not 0 <= int(mode, 8) <= 0o7777:
81+
raise qubes.exc.QubesValueError(
82+
f"invalid mode option '{mode}' in value '{value}'"
83+
)
84+
except ValueError:
85+
raise qubes.exc.QubesValueError(
86+
f"invalid mode option '{mode}' in value '{value}'"
87+
)
88+
89+
self._check_value_path(":".join(options[4:]))
90+
91+
def _write_db_value(self, feature, value, vm):
92+
vm.untrusted_qdb.write(
93+
"{}{}".format(QDB_PREFIX, self._extract_key_from_feature(feature)),
94+
str(value),
95+
)
96+
97+
@qubes.ext.handler("domain-qdb-create")
98+
def on_domain_qdb_create(self, vm, event):
99+
"""Actually export features"""
100+
# pylint: disable=unused-argument
101+
for feature, value in vm.features.items():
102+
if self._is_expected_feature(feature):
103+
self._check_key(self._extract_key_from_feature(feature))
104+
self._check_value(value)
105+
self._write_db_value(feature, value, vm)
106+
107+
@qubes.ext.handler("domain-feature-set:*")
108+
def on_domain_feature_set(self, vm, event, feature, value, oldvalue=None):
109+
"""Inject persist keys in QubesDB in runtime"""
110+
# pylint: disable=unused-argument
111+
112+
if not self._is_expected_feature(feature):
113+
return
114+
115+
self._check_key(self._extract_key_from_feature(feature))
116+
self._check_value(value)
117+
118+
if not vm.is_running():
119+
return
120+
121+
self._write_db_value(feature, value, vm)
122+
123+
@qubes.ext.handler("domain-feature-delete:*")
124+
def on_domain_feature_delete(self, vm, event, feature):
125+
"""Update /persist/ QubesDB tree in runtime"""
126+
# pylint: disable=unused-argument
127+
if not vm.is_running():
128+
return
129+
if not feature.startswith(FEATURE_PREFIX):
130+
return
131+
132+
vm.untrusted_qdb.rm(
133+
"{}{}".format(QDB_PREFIX, self._extract_key_from_feature(feature))
134+
)

qubes/tests/ext.py

+216
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import unittest.mock
2323

2424
import qubes.ext.core_features
25+
import qubes.ext.custom_persist
2526
import qubes.ext.services
2627
import qubes.ext.windows
2728
import qubes.ext.supported_features
@@ -1045,3 +1046,218 @@ def test_022_supported_rpc_remove(self):
10451046
"supported-rpc.test2": True,
10461047
},
10471048
)
1049+
1050+
1051+
class TC_40_CustomPersist(qubes.tests.QubesTestCase):
1052+
def setUp(self):
1053+
super().setUp()
1054+
self.ext = qubes.ext.custom_persist.CustomPersist()
1055+
self.features = {}
1056+
specs = {
1057+
"features.get.side_effect": self.features.get,
1058+
"features.items.side_effect": self.features.items,
1059+
"features.__iter__.side_effect": self.features.__iter__,
1060+
"features.__contains__.side_effect": self.features.__contains__,
1061+
"features.__setitem__.side_effect": self.features.__setitem__,
1062+
"features.__delitem__.side_effect": self.features.__delitem__,
1063+
}
1064+
1065+
vmspecs = {
1066+
**specs,
1067+
**{
1068+
"template": None,
1069+
},
1070+
}
1071+
self.vm = mock.MagicMock()
1072+
self.vm.configure_mock(**vmspecs)
1073+
1074+
def test_000_write_to_qdb(self):
1075+
self.features["custom-persist.home"] = "/home"
1076+
self.features["custom-persist.usrlocal"] = "/usr/local"
1077+
self.features["custom-persist.var_test"] = "/var/test"
1078+
1079+
self.ext.on_domain_qdb_create(self.vm, "domain-qdb-create")
1080+
self.assertEqual(
1081+
sorted(self.vm.untrusted_qdb.mock_calls),
1082+
[
1083+
mock.call.write("/persist/home", "/home"),
1084+
mock.call.write("/persist/usrlocal", "/usr/local"),
1085+
mock.call.write("/persist/var_test", "/var/test"),
1086+
],
1087+
)
1088+
1089+
def test_001_feature_set(self):
1090+
self.ext.on_domain_feature_set(
1091+
self.vm,
1092+
"feature-set:custom-persist.test_no_oldvalue",
1093+
"custom-persist.test_no_oldvalue",
1094+
"/test_no_oldvalue",
1095+
)
1096+
self.ext.on_domain_feature_set(
1097+
self.vm,
1098+
"feature-set:custom-persist.test_oldvalue",
1099+
"custom-persist.test_oldvalue",
1100+
"/newvalue",
1101+
"",
1102+
)
1103+
1104+
self.assertEqual(
1105+
sorted(self.vm.untrusted_qdb.mock_calls),
1106+
[
1107+
mock.call.write(
1108+
"/persist/test_no_oldvalue", "/test_no_oldvalue"
1109+
),
1110+
mock.call.write("/persist/test_oldvalue", "/newvalue"),
1111+
],
1112+
)
1113+
1114+
def test_002_feature_delete(self):
1115+
self.ext.on_domain_feature_delete(
1116+
self.vm, "feature-delete:custom-persist.test", "custom-persist.test"
1117+
)
1118+
self.vm.untrusted_qdb.rm.assert_called_with("/persist/test")
1119+
1120+
def test_003_empty_key(self):
1121+
with self.assertRaises(qubes.exc.QubesValueError) as e:
1122+
self.ext.on_domain_feature_set(
1123+
self.vm,
1124+
"feature-set:custom-persist.",
1125+
"custom-persist.",
1126+
"/test",
1127+
"",
1128+
)
1129+
self.assertEqual(str(e.exception), "custom-persist key cannot be empty")
1130+
self.vm.untrusted_qdb.write.assert_not_called()
1131+
1132+
def test_004_key_too_long(self):
1133+
with self.assertRaises(qubes.exc.QubesValueError) as e:
1134+
self.ext.on_domain_feature_set(
1135+
self.vm,
1136+
"feature-set:custom-persist." + "X" * 55,
1137+
"custom-persist." + "X" * 55,
1138+
"/test",
1139+
"",
1140+
)
1141+
1142+
self.assertEqual(
1143+
str(e.exception),
1144+
"custom-persist key is too long (max 54), ignoring: " + "X" * 55,
1145+
)
1146+
self.vm.untrusted_qdb.assert_not_called()
1147+
1148+
def test_005_other_feature_deletion(self):
1149+
self.ext.on_domain_feature_delete(
1150+
self.vm, "feature-delete:otherfeature.test", "otherfeature.test"
1151+
)
1152+
self.vm.untrusted_qdb.assert_not_called()
1153+
1154+
def test_006_feature_set_while_vm_is_not_running(self):
1155+
self.vm.is_running.return_value = False
1156+
self.ext.on_domain_feature_set(
1157+
self.vm,
1158+
"feature-set:custom-persist.test",
1159+
"custom-persist.test",
1160+
"/test",
1161+
)
1162+
self.vm.untrusted_qdb.write.assert_not_called()
1163+
1164+
def test_007_feature_set_value_with_option(self):
1165+
self.ext.on_domain_feature_set(
1166+
self.vm,
1167+
"feature-set:custom-persist.test",
1168+
"custom-persist.test",
1169+
"dir:root:root:0755:/var/test",
1170+
"",
1171+
)
1172+
self.vm.untrusted_qdb.write.assert_called_with(
1173+
"/persist/test", "dir:root:root:0755:/var/test"
1174+
)
1175+
1176+
def test_008_feature_set_invalid_path(self):
1177+
with self.assertRaises(qubes.exc.QubesValueError):
1178+
self.ext.on_domain_feature_set(
1179+
self.vm,
1180+
"feature-set:custom-persist.test",
1181+
"custom-persist.test",
1182+
"test",
1183+
"",
1184+
)
1185+
self.vm.untrusted_qdb.write.assert_not_called()
1186+
1187+
def test_009_feature_set_invalid_option_type(self):
1188+
with self.assertRaises(qubes.exc.QubesValueError):
1189+
self.ext.on_domain_feature_set(
1190+
self.vm,
1191+
"feature-set:custom-persist.test",
1192+
"custom-persist.test",
1193+
"bad:root:root:0755:/var/test",
1194+
"",
1195+
)
1196+
self.vm.untrusted_qdb.write.assert_not_called()
1197+
1198+
def test_010_feature_set_invalid_option_mode_too_high(self):
1199+
with self.assertRaises(qubes.exc.QubesValueError):
1200+
self.ext.on_domain_feature_set(
1201+
self.vm,
1202+
"feature-set:custom-persist.test",
1203+
"custom-persist.test",
1204+
"file:root:root:9750:/var/test",
1205+
"",
1206+
)
1207+
self.vm.untrusted_qdb.write.assert_not_called()
1208+
1209+
def test_011_feature_set_invalid_option_mode_negative_high(self):
1210+
with self.assertRaises(qubes.exc.QubesValueError):
1211+
self.ext.on_domain_feature_set(
1212+
self.vm,
1213+
"feature-set:custom-persist.test",
1214+
"custom-persist.test",
1215+
"file:root:root:-755:/var/test",
1216+
"",
1217+
)
1218+
self.vm.untrusted_qdb.write.assert_not_called()
1219+
1220+
def test_012_feature_set_option_mode_without_leading_zero(self):
1221+
self.ext.on_domain_feature_set(
1222+
self.vm,
1223+
"feature-set:custom-persist.test",
1224+
"custom-persist.test",
1225+
"file:root:root:755:/var/test",
1226+
"",
1227+
)
1228+
self.vm.untrusted_qdb.write.assert_called_with(
1229+
"/persist/test", "file:root:root:755:/var/test"
1230+
)
1231+
1232+
def test_013_feature_set_invalid_path_with_option(self):
1233+
with self.assertRaises(qubes.exc.QubesValueError):
1234+
self.ext.on_domain_feature_set(
1235+
self.vm,
1236+
"feature-set:custom-persist.test",
1237+
"custom-persist.test",
1238+
"dir:root:root:0755:var/test",
1239+
"",
1240+
)
1241+
self.vm.untrusted_qdb.write.assert_not_called()
1242+
1243+
def test_014_feature_set_path_with_colon_with_options(self):
1244+
self.ext.on_domain_feature_set(
1245+
self.vm,
1246+
"feature-set:custom-persist.test",
1247+
"custom-persist.test",
1248+
"file:root:root:755:/var/test:dir:with:colon",
1249+
"",
1250+
)
1251+
self.vm.untrusted_qdb.write.assert_called()
1252+
1253+
def test_015_feature_set_path_with_colon_without_options(self):
1254+
self.ext.on_domain_feature_set(
1255+
self.vm,
1256+
"feature-set:custom-persist.test",
1257+
"custom-persist.test",
1258+
"/var/test:dir:with:colon",
1259+
"",
1260+
)
1261+
self.vm.untrusted_qdb.write.assert_called_with(
1262+
"/persist/test", "/var/test:dir:with:colon"
1263+
)

rpm_spec/core-dom0.spec.in

+1
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ done
443443
%{python3_sitelib}/qubes/ext/backup_restore.py
444444
%{python3_sitelib}/qubes/ext/block.py
445445
%{python3_sitelib}/qubes/ext/core_features.py
446+
%{python3_sitelib}/qubes/ext/custom_persist.py
446447
%{python3_sitelib}/qubes/ext/gui.py
447448
%{python3_sitelib}/qubes/ext/audio.py
448449
%{python3_sitelib}/qubes/ext/pci.py

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def run(self):
6464
'qubes.ext.backup_restore = '
6565
'qubes.ext.backup_restore:BackupRestoreExtension',
6666
'qubes.ext.core_features = qubes.ext.core_features:CoreFeatures',
67+
'qubes.ext.custom_persist = qubes.ext.custom_persist:CustomPersist',
6768
'qubes.ext.gui = qubes.ext.gui:GUI',
6869
'qubes.ext.audio = qubes.ext.audio:AUDIO',
6970
'qubes.ext.r3compatibility = qubes.ext.r3compatibility:R3Compatibility',

0 commit comments

Comments
 (0)