From 58344c204e0025a308264960ffec5480858f942b Mon Sep 17 00:00:00 2001 From: Mounir IDRASSI Date: Sat, 3 Oct 2026 17:32:02 +0900 Subject: [PATCH] Linux: forward FUSE volume fsync to the backing storage Volumes encrypted in user space are mounted through a loop device on the FUSE volume image, and their writes end in buffered pwrite() calls to the backing file or device. The FUSE service had no fsync callback, so libfuse answered the fsync requests that the loop device issues for block-layer flushes with ENOSYS, which Linux FUSE reports as success. Synced data could therefore remain dirty in the host cache, and writeback errors were not reported. Register an fsync callback that syncs the backing storage for the volume image and returns its errors through the existing mapping. Never return ENOSYS, which would make Linux FUSE stop forwarding fsync for the whole mount: report a backing ENOSYS as EIO, as the loop driver does, and return success for the other files. Other Unix platforms are outside this change. Add regression coverage on device- and file-backed volumes: fsync must flush the backing device, a backing failure must be reported as EIO, and synced data must survive a simulated power cut. Without this change, the synced data is lost. Validated with FUSE2 and FUSE3 builds. --- Tests/test_linux_fuse_fsync.py | 290 ++++++++++++++++++++++++++++++++ src/Driver/Fuse/FuseService.cpp | 37 ++++ src/Driver/Fuse/FuseService.h | 1 + 3 files changed, 328 insertions(+) create mode 100644 Tests/test_linux_fuse_fsync.py diff --git a/Tests/test_linux_fuse_fsync.py b/Tests/test_linux_fuse_fsync.py new file mode 100644 index 00000000..970e3394 --- /dev/null +++ b/Tests/test_linux_fuse_fsync.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Check that Linux FUSE volumes forward fsync to the encrypted backing storage. + +Usage: + sudo python3 Tests/test_linux_fuse_fsync.py --binary /path/to/veracrypt + +Requires root, util-linux, dmsetup and mkfs.ext4. Each check uses a disposable +volume on a sparse image behind a loop device and a device-mapper target: one +volume on the device itself and one file container in an ext4 filesystem on +it. Volumes are mounted with --mount-options=nokernelcrypto, so their data +goes through the FUSE volume image and the loop device attached to it. +For each volume, the test checks that: + + - fsync in the mounted filesystem flushes the backing device, also after + an fsync of another file in the FUSE mount, + - fsync reports EIO once the backing device fails. The device-mapper table + is replaced without syncing, which simulates a power cut, + - data synced before the cut is intact on the backing image, also when + mounted read-only. + +Mounts are made in a private mount namespace, so other processes cannot keep +them busy. Every unmount targets a test volume explicitly. Artifacts are kept +in a private temporary directory. +""" + +import argparse +import ctypes +import errno +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +import traceback + +CLONE_NEWNS = 0x00020000 +TOOLS = ("blockdev", "dmsetup", "losetup", "mkfs.ext4", "mount", "umount") + + +class FsyncChecks: + def __init__(self, args): + self.binary = str(Path(args.binary).resolve()) + self.slot = args.slot + self.root = Path(tempfile.mkdtemp(prefix="veracrypt-fsync-test-")).resolve() + self.mountpoint = self.root / "mount" + self.host = self.root / "host" + self.mountpoint.mkdir() + self.host.mkdir() + self.password = "Disposable-FUSE-fsync-test-only" + self.results = [] + self.failures = [] + self.volume = None + self.host_mounted = False + self.loop = None + self.dm = None + print("Artifacts:", self.root, flush=True) + + def record(self, entry): + self.results.append(entry) + (self.root / "results.json").write_text(json.dumps(self.results, indent=2)) + + def run(self, label, args, expected=0): + started = time.monotonic() + result = subprocess.run(list(map(str, args)), capture_output=True, text=True, timeout=300) + output = result.stdout + result.stderr + (self.root / (label + ".log")).write_text(output) + self.record(dict(label=label, returncode=result.returncode, + seconds=round(time.monotonic() - started, 3), output=output)) + if expected is not None and result.returncode != expected: + raise AssertionError(f"{label}: expected {expected}, got {result.returncode}: {output}") + return result + + def vc(self, label, *args, expected=0): + return self.run(label, [self.binary, "--text", "--non-interactive", *args], expected) + + def expect(self, label, passed, detail): + self.record(dict(label=label, passed=passed, detail=detail)) + print("PASS" if passed else "FAIL", label + ":", detail, flush=True) + if not passed: + self.failures.append(label) + + def slot_volumes(self): + result = self.vc("list", "--list", expected=None) + return [line.split() for line in result.stdout.splitlines() + if line.startswith(f"{self.slot}: ")] + + def mount(self, label, volume, *args, options="nokernelcrypto"): + self.volume = volume + self.vc(label, "--mount", volume, *args, f"--slot={self.slot}", + "--mount-options=" + options, "--password=" + self.password, + "--pim=1", "--keyfiles=", "--protect-hidden=no") + listed = self.slot_volumes() + if len(listed) != 1 or os.path.realpath(listed[0][1]) != os.path.realpath(volume): + raise AssertionError(f"{volume} is not listed in slot {self.slot}: {listed}") + # Only a loop device on the FUSE volume image uses the path under test. + device = Path(listed[0][2]) + backing = Path("/sys/block", device.name, "loop", "backing_file") + image = Path(backing.read_text().strip()) if backing.exists() else None + if image is None or image.name != "volume" or not (image.parent / "control").exists(): + raise AssertionError(f"{device} is not attached to a FUSE volume image") + return device, image.parent + + def unmount(self, label): + if self.vc(label, "--unmount", self.volume, expected=None).returncode != 0: + self.vc(label + "-force", "--force", "--unmount", self.volume) + self.volume = None + + def mount_host(self, label, device): + # Keep periodic journal commits from flushing the backing device. + self.run(label, ["mount", "-o", "commit=600", device, self.host]) + self.host_mounted = True + + def cut_power(self, label, sectors): + # Without lockfs, suspending syncs nothing above the target. Data that + # has not reached the loop device is lost, as in a power cut. + self.run(label + "-suspend", ["dmsetup", "suspend", "--nolockfs", self.dm]) + self.run(label + "-load", ["dmsetup", "load", self.dm, "--table", f"0 {sectors} error"]) + self.run(label + "-resume", ["dmsetup", "resume", self.dm]) + + @staticmethod + def flushes(device): + # Completed flush requests (Documentation/block/stat.rst). Device-mapper + # targets do not count them, so the loop device below is measured. + fields = Path("/sys/block", Path(device).name, "stat").read_text().split() + if len(fields) < 17: + raise AssertionError("This kernel does not report flush statistics") + return int(fields[15]) + + @staticmethod + def write_file(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + view = memoryview(data) + while view: + view = view[os.write(fd, view):] + try: + os.fsync(fd) + except OSError as e: + return e.errno + return 0 + finally: + os.close(fd) + + def compare(self, synced): + found = {} + for name, digest in synced.items(): + try: + data = (self.mountpoint / name).read_bytes() + found[name] = "intact" if hashlib.sha256(data).hexdigest() == digest else "changed" + except OSError as e: + found[name] = errno.errorcode.get(e.errno, str(e.errno)) + return all(state == "intact" for state in found.values()), found + + def check(self, kind): + image = self.root / (kind + ".img") + with open(image, "wb") as f: + f.truncate(128 << 20) + self.loop = self.run(kind + "-losetup", ["losetup", "--find", "--show", image]).stdout.strip() + sectors = int(self.run(kind + "-size", ["blockdev", "--getsz", self.loop]).stdout) + self.dm = f"veracrypt-fsync-test-{os.getpid()}-{kind}" + self.run(kind + "-dm", ["dmsetup", "create", self.dm, "--table", f"0 {sectors} linear {self.loop} 0"]) + if kind == "file": + self.run(kind + "-host-mkfs", ["mkfs.ext4", "-q", "-F", "-E", "lazy_itable_init=0,lazy_journal_init=0", + "/dev/mapper/" + self.dm]) + self.mount_host(kind + "-host-mount", "/dev/mapper/" + self.dm) + volume = self.host / "test.hc" + else: + volume = Path("/dev/mapper", self.dm) + + self.vc(kind + "-create", "--create", volume, *(["--size=64M"] if kind == "file" else []), + "--volume-type=normal", "--encryption=AES", "--hash=SHA-512", "--filesystem=none", + "--password=" + self.password, "--pim=1", "--keyfiles=", "--random-source=/dev/urandom") + device, _ = self.mount(kind + "-mount-raw", volume, "--filesystem=none") + self.run(kind + "-mkfs", ["mkfs.ext4", "-q", "-F", "-b", "4096", + "-E", "lazy_itable_init=0,lazy_journal_init=0", device]) + self.unmount(kind + "-unmount-raw") + + device, aux = self.mount(kind + "-mount", volume, self.mountpoint) + # Linux FUSE stops forwarding fsync on the whole mount after any ENOSYS + # reply, so sync another file first. + fd = os.open(aux / "control", os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + synced = {} + counts = [] + for i in range(3): + name = f"synced{i}" + data = os.urandom(1 << 20) + before = self.flushes(device), self.flushes(self.loop) + result = self.write_file(self.mountpoint / name, data) + if result: + raise AssertionError(f"fsync of {name} failed: {os.strerror(result)}") + counts.append([self.flushes(device) - before[0], self.flushes(self.loop) - before[1]]) + synced[name] = hashlib.sha256(data).hexdigest() + self.expect(kind + "-flush-forwarded", all(volume_flushes and backing_flushes + for volume_flushes, backing_flushes in counts), + f"flushes per fsync on the volume loop device and the backing device: {counts}") + + self.cut_power(kind + "-cut", sectors) + result = self.write_file(self.mountpoint / "unsynced", os.urandom(1 << 20)) + self.expect(kind + "-sync-error-returned", result == errno.EIO, + "fsync after the backing device failed: " + + (errno.errorcode.get(result, str(result)) if result else "success")) + self.unmount(kind + "-unmount-cut") + + # Inspect what reached the backing image before the cut. + if kind == "file": + self.run(kind + "-host-unmount-cut", ["umount", self.host]) + self.host_mounted = False + self.mount_host(kind + "-host-mount-image", self.loop) + else: + volume = Path(self.loop) + try: + self.mount(kind + "-mount-image", volume, self.mountpoint) + except AssertionError as e: + self.expect(kind + "-synced-data-durable", False, + "volume did not mount after the cut: " + str(e).splitlines()[0]) + return + self.expect(kind + "-synced-data-durable", *self.compare(synced)) + self.unmount(kind + "-unmount-image") + self.mount(kind + "-mount-readonly", volume, self.mountpoint, options="ro,nokernelcrypto") + self.expect(kind + "-readonly-mount", *self.compare(synced)) + self.unmount(kind + "-unmount-readonly") + + def cleanup(self, kind): + if self.volume is not None: + self.vc(kind + "-cleanup-unmount", "--force", "--unmount", self.volume, expected=None) + self.volume = None + if self.host_mounted: + self.run(kind + "-cleanup-host", ["umount", self.host], expected=None) + self.host_mounted = False + if self.dm: + if self.run(kind + "-cleanup-dm", ["dmsetup", "remove", "--retry", self.dm], expected=None).returncode: + # Removed when its last user closes it. + self.run(kind + "-cleanup-dm-deferred", ["dmsetup", "remove", "--deferred", self.dm], expected=None) + self.dm = None + if self.loop: + self.run(kind + "-cleanup-loop", ["losetup", "--detach", self.loop], expected=None) + self.loop = None + (self.root / (kind + ".img")).unlink(missing_ok=True) + + +def isolate_mounts(): + # Mount namespaces created by other processes during the test would copy + # its mounts and keep the backing devices busy after cleanup. + libc = ctypes.CDLL(None, use_errno=True) + if libc.unshare(CLONE_NEWNS) != 0: + error = ctypes.get_errno() + raise OSError(error, "unshare: " + os.strerror(error)) + subprocess.run(["mount", "--make-rprivate", "/"], check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--binary", required=True) + parser.add_argument("--slot", type=int, default=61, help="free slot for the test volumes (default: 61)") + parser.add_argument("--backing", choices=("device", "file"), action="append", + help="backing storage to check (default: both)") + args = parser.parse_args() + if not sys.platform.startswith("linux") or os.geteuid() != 0: + parser.error("Run on Linux as root") + missing = [tool for tool in TOOLS if not shutil.which(tool)] + if missing: + parser.error("Missing tools: " + ", ".join(missing)) + isolate_mounts() + checks = FsyncChecks(args) + if checks.slot_volumes(): + parser.error(f"Slot {args.slot} is in use; choose a free one with --slot") + for kind in args.backing or ("device", "file"): + try: + checks.check(kind) + except Exception as e: + traceback.print_exc() + checks.expect(kind + "-completed", False, f"{type(e).__name__}: {e}") + finally: + checks.cleanup(kind) + if checks.failures: + sys.exit("Failed: " + ", ".join(checks.failures)) + print("All checks passed") + + +if __name__ == "__main__": + main() diff --git a/src/Driver/Fuse/FuseService.cpp b/src/Driver/Fuse/FuseService.cpp index 006b7355..a92c6893 100644 --- a/src/Driver/Fuse/FuseService.cpp +++ b/src/Driver/Fuse/FuseService.cpp @@ -1144,6 +1144,32 @@ namespace VeraCrypt return -ENOENT; } +#ifdef TC_LINUX + static int fuse_service_fsync (const char *path, int datasync, struct fuse_file_info *fi) + { + try + { + if (!FuseService::CheckAccessRights()) + return -EACCES; + + // Loop devices turn block-layer flushes into fsync requests on the volume image. + // Data written by WriteVolumeSectors() reaches the backing storage only when it is synced. + if (strcmp (path, FuseService::GetVolumeImagePath()) == 0) + FuseService::FlushVolume(); + } + catch (...) + { + // Never return -ENOSYS, even if the backing storage does: Linux FUSE would then + // report success for every later fsync on this mount without forwarding it. + int error = FuseService::ExceptionToErrorCode(); + return error == -ENOSYS ? -EIO : error; + } + + // Other files have nothing to sync, and must not get -ENOSYS either. + return 0; + } +#endif + bool FuseService::CheckAccessRights () { return fuse_get_context()->uid == 0 || fuse_get_context()->uid == UserId; @@ -1212,6 +1238,14 @@ namespace VeraCrypt } } + void FuseService::FlushVolume () + { + if (!MountedVolume) + throw NotInitialized (SRC_POS); + + MountedVolume->GetFile()->Flush(); + } + shared_ptr FuseService::GetAuxDeviceInfo () { shared_ptr stream (new MemoryStream); @@ -1750,6 +1784,9 @@ namespace VeraCrypt fuse_service_oper.access = fuse_service_access; fuse_service_oper.destroy = fuse_service_destroy; +#ifdef TC_LINUX + fuse_service_oper.fsync = fuse_service_fsync; +#endif fuse_service_oper.getattr = fuse_service_getattr; fuse_service_oper.init = fuse_service_init; fuse_service_oper.open = fuse_service_open; diff --git a/src/Driver/Fuse/FuseService.h b/src/Driver/Fuse/FuseService.h index 8007c55a..cb37a412 100644 --- a/src/Driver/Fuse/FuseService.h +++ b/src/Driver/Fuse/FuseService.h @@ -55,6 +55,7 @@ namespace VeraCrypt static bool CheckAccessRights (); static void Dismount (); static int ExceptionToErrorCode (); + static void FlushVolume (); static const char *GetAuxDeviceInfoPath () { return "/aux-device-info"; } static const char *GetControlPath () { return "/control"; } static const char *GetVolumeImagePath ();