iotests: test O_TRUNC behavior for fuse exports
The test cases for the blockdev-based export and for the file-based export with growable=on work before commit a94a1d7699 ("fuse: Manually process requests (without libfuse)"), then are broken until commit "block/export/fuse: fix regression with O_TRUNC when export is growable". The test case for the blockdev-based export requires passwordless sudo for losetup and chmod similar to test 108. Signed-off-by: Fiona Ebner <f.ebner@proxmox.com> Message-ID: <20260702132256.661429-3-f.ebner@proxmox.com> [kwolf: Catch OSError when probing sudo support] Reviewed-by: Kevin Wolf <kwolf@redhat.com> Signed-off-by: Kevin Wolf <kwolf@redhat.com>
Fiona Ebner committed
Jul 2, 2026 at 15:22 UTC
7d06bbb2d9ba77e45b5a3131c9e7f934df99d5bc
2 files changed
+176
tests/qemu-iotests/tests/fuse-truncate
new
+171
@@ -0,0 +1,171 @@
1
+#!/usr/bin/env python3
2
+# group: rw
3
+#
4
+# Test how fuse exports behave with regard to O_TRUNC.
5
+#
6
+# Copyright (C) 2026 Proxmox Server Solutions GmbH
7
+#
8
+# SPDX-License-Identifier: GPL-2.0-or-later
9
+
10
+import os
11
+import subprocess
12
+from pathlib import Path
13
+
14
+import iotests
15
+from iotests import qemu_img, QemuStorageDaemon
16
+
17
+fuse_mount_point = os.path.join(iotests.test_dir, 'export.fuse')
18
+image_size = 1 * 1024 * 1024
19
+image = os.path.join(iotests.test_dir, 'image.' + iotests.imgfmt)
20
+
21
+def check_fuse_support():
22
+ Path(fuse_mount_point).touch()
23
+ test_qsd = QemuStorageDaemon('--blockdev', 'null-co,node-name=node0',
24
+ qmp=True)
25
+ res = test_qsd.qmp('block-export-add', {
26
+ 'id': 'exp0',
27
+ 'type': 'fuse',
28
+ 'node-name': 'node0',
29
+ 'mountpoint': fuse_mount_point,
30
+ 'allow-other': 'off'
31
+ })
32
+ test_qsd.stop()
33
+ os.remove(fuse_mount_point)
34
+ if 'error' in res:
35
+ assert (res['error']['desc'] ==
36
+ "Parameter 'type' does not accept value 'fuse'")
37
+ iotests.notrun('No FUSE support')
38
+
39
+def check_sudo_support():
40
+ try:
41
+ subprocess.run(['sudo', '-n', 'losetup', '--version'],
42
+ capture_output=True, check=True)
43
+ except (OSError, subprocess.CalledProcessError):
44
+ return False
45
+ try:
46
+ subprocess.run(['sudo', '-n', 'chmod', '--version'],
47
+ capture_output=True, check=True)
48
+ except (OSError, subprocess.CalledProcessError):
49
+ return False
50
+ return True
51
+
52
+check_fuse_support()
53
+
54
+class TestTruncateBase(iotests.QMPTestCase):
55
+ growable = False
56
+ supports_preconditions = True
57
+
58
+ def evaluate_preconditions(self):
59
+ return
60
+
61
+ def add_blockdev(self):
62
+ qemu_img('create', '-f', iotests.imgfmt, image, str(image_size))
63
+ self.qsd.cmd('blockdev-add', {
64
+ 'node-name': 'node0',
65
+ 'driver': iotests.imgfmt,
66
+ 'file': {
67
+ 'driver': 'file',
68
+ 'filename': image
69
+ }
70
+ })
71
+
72
+ def cleanup_blockdev(self):
73
+ os.remove(image)
74
+
75
+ def add_export(self):
76
+ self.qsd.cmd('block-export-add', {
77
+ 'id': 'exp0',
78
+ 'type': 'fuse',
79
+ 'node-name': 'node0',
80
+ 'mountpoint': fuse_mount_point,
81
+ 'growable': self.growable,
82
+ 'writable': True,
83
+ 'allow-other': 'off'
84
+ })
85
+
86
+ def stop_qsd(self):
87
+ if self.qsd:
88
+ self.qsd.cmd('block-export-del', {'id': 'exp0'})
89
+ self.qsd.stop()
90
+ self.qsd = None
91
+
92
+ def setUp(self):
93
+ self.evaluate_preconditions()
94
+ if not self.supports_preconditions:
95
+ return
96
+ Path(fuse_mount_point).touch()
97
+ self.qsd = QemuStorageDaemon(qmp=True)
98
+ self.add_blockdev()
99
+ self.add_export()
100
+
101
+ def tearDown(self):
102
+ if not self.supports_preconditions:
103
+ return
104
+ self.stop_qsd()
105
+ self.cleanup_blockdev()
106
+ os.remove(fuse_mount_point)
107
+
108
+class TestTruncateFileGrowable(TestTruncateBase):
109
+ growable = True
110
+
111
+ def test_o_trunc(self):
112
+ with open(fuse_mount_point, 'w+b') as file:
113
+ file.seek(0, os.SEEK_END)
114
+ self.assertEqual(file.tell(), 0)
115
+ file.write(b"test")
116
+ self.stop_qsd()
117
+
118
+class TestTruncateFileNotGrowable(TestTruncateBase):
119
+ growable = False
120
+
121
+ def test_o_trunc(self):
122
+ with open(fuse_mount_point, 'w+b') as file:
123
+ file.seek(0, os.SEEK_END)
124
+ self.assertEqual(file.tell(), image_size)
125
+ file.seek(0, os.SEEK_SET)
126
+ file.write(b"test")
127
+ self.stop_qsd()
128
+
129
+class TestTruncateBlockdev(TestTruncateBase):
130
+ growable = False
131
+
132
+ def evaluate_preconditions(self):
133
+ self.supports_preconditions = check_sudo_support()
134
+
135
+ def add_blockdev(self):
136
+ qemu_img('create', '-f', iotests.imgfmt, image, str(image_size))
137
+ res = subprocess.run(['sudo', '-n', 'losetup', '--show', '-f', image],
138
+ check=True, capture_output=True, text=True)
139
+ self.loopdev = res.stdout.strip()
140
+ subprocess.run(['sudo', '-n', 'chmod', 'go+rw', self.loopdev],
141
+ check=True, capture_output=True)
142
+ self.qsd.cmd('blockdev-add', {
143
+ 'node-name': 'node0',
144
+ 'driver': iotests.imgfmt,
145
+ 'file': {
146
+ 'driver': 'host_device',
147
+ 'filename': self.loopdev
148
+ }
149
+ })
150
+
151
+ def cleanup_blockdev(self):
152
+ subprocess.run(['sudo', '-n', 'losetup', '--detach', self.loopdev],
153
+ check=True, capture_output=True)
154
+ os.remove(image)
155
+
156
+ def test_o_trunc(self):
157
+ if not self.supports_preconditions:
158
+ iotests.case_notrun('No passwordless sudo for losetup and chmod')
159
+ return
160
+
161
+ with open(fuse_mount_point, 'w+b') as file:
162
+ file.seek(0, os.SEEK_END)
163
+ self.assertEqual(file.tell(), image_size)
164
+ file.seek(0, os.SEEK_SET)
165
+ file.write(b"test")
166
+ self.stop_qsd()
167
+
168
+if __name__ == '__main__':
169
+ iotests.main(supported_fmts=['raw'],
170
+ supported_protocols=['file'],
171
+ supported_platforms=['linux'])
tests/qemu-iotests/tests/fuse-truncate.out
new
+5
@@ -0,0 +1,5 @@
1
+...
2
+----------------------------------------------------------------------
3
+Ran 3 tests
4
+
5
+OK