From a62d0782200ba84356146ccc359e2fd7c591f693 Mon Sep 17 00:00:00 2001 From: Andrea Mazzoleni Date: Thu, 7 May 2026 17:23:48 +0200 Subject: [PATCH] Add Windows VirtualShadowCopy support for snapshots --- HISTORY | 10 +- cmdline/mingw.c | 443 +++++++++++++++++++++++++++++++--- cmdline/portable.h | 14 +- cmdline/state.c | 3 - doc/snapraid.1 | 8 +- doc/snapraid.d | 8 +- doc/snapraid.txt | 8 +- snapraid.conf.example.windows | 28 +++ 8 files changed, 468 insertions(+), 54 deletions(-) diff --git a/HISTORY b/HISTORY index 26579c59..1d877268 100644 --- a/HISTORY +++ b/HISTORY @@ -4,10 +4,12 @@ SnapRAID HISTORY 15.0 WIP ======== * Added support for automated filesystem snapshots to ensure data - consistency and improve recovery reliability. When the 'snapshot' - option is enabled, SnapRAID manages a 'pending' snapshot at the start - of a sync and promotes it to a 'stable' state upon successful - completion (currently supported on Btrfs, Bcachefs, and ZFS). + consistency and improve recovery reliability. + When the 'snapshot' option is enabled, SnapRAID transparently manages + filesystem snapshots during operations, requiring no changes to the + existing user workflow or file management. + Supported on Btrfs, Bcachefs, and ZFS in Linux, and on NTFS in + Windows. This ensures that 'scrub', 'check', and 'fix' operations utilize a frozen point-in-time reference for data disks, eliminating mismatches caused by concurrent live filesystem modifications. diff --git a/cmdline/mingw.c b/cmdline/mingw.c index 0c2b402e..d48c724e 100644 --- a/cmdline/mingw.c +++ b/cmdline/mingw.c @@ -2078,43 +2078,6 @@ int fsinfo(const char* path, int* has_persistent_inode, int* has_syncronized_har return 0; } -int fssnapshot(const char* path, struct fssnapshot_struct* fss) -{ - (void)path; - (void)fss; - return -1; -} - -int fssnapshot_stat(struct fssnapshot_struct* fss, const char* name, struct stat* st) -{ - (void)fss; - (void)name; - (void)st; - return 0; -} - -int fssnapshot_create(const struct fssnapshot_struct* fss, const char* name) -{ - (void)fss; - (void)name; - return -1; -} - -int fssnapshot_delete(const struct fssnapshot_struct* fss, const char* name) -{ - (void)fss; - (void)name; - return -1; -} - -int fssnapshot_rename(const struct fssnapshot_struct* fss, const char* old_name, const char* new_name) -{ - (void)fss; - (void)old_name; - (void)new_name; - return -1; -} - /* ensure to call the real C strerror() */ #undef strerror @@ -2338,6 +2301,412 @@ size_t windows_direct_size(void) return si.dwPageSize; } +#define WINDOWS_NTFS_MAGIC 0x5346544E /* 'N','T','F','S' */ + +#define PS_CMD_MAX 4096 + +#define SNAPSHOT_GUID ".guid" + +/* + * PowerShell helper + * + * Runs a PowerShell one-liner via _popen() and captures the first line + * of stdout into `out` as UTF-8. + * `out` may be NULL when output is not needed. + * + * The caller builds `command` as a string using snprintf(). + * Any embedded path literals must use single quotes so they survive + * the outer double-quote wrapping passed to cmd.exe. + * + * Returns 0 on success, -1 on failure. + */ +static int windows_ps(const char* ps_command, char* out, size_t out_size) +{ + char cmd[PS_CMD_MAX]; + FILE* fp; + int ret; + + ret = snprintf(cmd, sizeof(cmd), "powershell.exe -NoProfile -NonInteractive -Command \"%s\" 2>nul", ps_command); + if (ret < 0 || ret >= (int)sizeof(cmd)) { + errno = EINVAL; + return -1; + } + + fp = _popen(cmd, "r"); + if (!fp) { + windows_errno(GetLastError()); + log_error(errno, "Failed to run PowerShell command '%s' (from popen).\n", cmd); + return -1; + } + + if (out && out_size > 0) { + /* get the first line */ + if (!fgets(out, (int)out_size, fp)) { + out[0] = 0; + } else { + /* trim spaces and newlines */ + strtrim(out); + } + } + + ret = _pclose(fp); + if (ret == -1) { + errno = EINVAL; + log_error(errno, "Failed to run PowerShell command '%s' (from pclose).\n", cmd); + return -1; + } + if (ret != 0) { + errno = EINVAL; + log_error(errno, "PowerShell command '%s' failed with '%d' (from pclose).\n", cmd, ret); + return -1; + } + + return 0; +} + +static int windows_read_guid(const char* guid_link, char* guid, size_t guid_size) +{ + int len = windows_readlink(guid_link, guid, guid_size); + if (len < 0 && errno == ENOENT) { + guid[0] = 0; + return 0; + } + if (len < 0) { + log_error(errno, "Error readlink '%s'. %s.\n", guid_link, strerror(errno)); + return -1; + } + + if ((size_t)len >= guid_size) { + errno = ENAMETOOLONG; + log_error(errno, "GUID too long in '%s'.\n", guid_link); + return -1; + } + + guid[len] = 0; + return 0; +} + +static int windows_rebuild_link(const struct fssnapshot_struct* fss, const char* name) +{ + char cmd[PS_CMD_MAX]; + char device_path[PATH_MAX]; + char target_path[PATH_MAX]; + char link_path[PATH_MAX]; + char guid_link[PATH_MAX]; + char guid[PATH_MAX]; + + pathcpy(guid_link, sizeof(guid_link), fss->snapshot_dir); + pathcat(guid_link, sizeof(guid_link), name); + pathcat(guid_link, sizeof(guid_link), SNAPSHOT_GUID); + + pathcpy(link_path, sizeof(link_path), fss->snapshot_dir); + pathcat(link_path, sizeof(link_path), name); + + int ret = windows_read_guid(guid_link, guid, sizeof(guid)); + if (ret != 0) { + return -1; + } + + /* if no GUID, assume it absent */ + if (guid[0] == 0) { + /* remove stale links */ + windows_rmdir(link_path); + return 0; + } + + snprintf(cmd, sizeof(cmd), + "(Get-WmiObject Win32_ShadowCopy | Where-Object {$_.ID -eq '%s'}).DeviceObject", + guid); + + if (windows_ps(cmd, device_path, sizeof(device_path)) != 0) { + log_error(errno, "Error getting DeviceObject from GUID '%s'. %s.\n", guid, strerror(errno)); + return -1; + } + + /* if no device path, assume snapshot absent */ + if (device_path[0] == 0) { + /* remove stale links */ + windows_rmdir(guid_link); + windows_rmdir(link_path); + return 0; + } + + pathimport(target_path, sizeof(target_path), device_path); + pathslash(target_path, sizeof(target_path)); + + /* remove stale link if present */ + windows_rmdir(link_path); + + if (windows_symlink_directory(target_path, link_path) != 0) { + log_error(errno, "Error creating symlink '%s'. %s.\n", link_path, strerror(errno)); + return -1; + } + + return 0; +} + +int fssnapshot(const char* dir, struct fssnapshot_struct* fss) +{ + wchar_t conv_buf_vol[CONV_MAX]; + char conv_buf_root[CONV_MAX]; + wchar_t volume_root[PATH_MAX]; + wchar_t volume_name[PATH_MAX]; + wchar_t fs_name[32]; + uint32_t magic; + + /* + * GetVolumePathNameW() accepts any path: file, directory, or deep + * subdirectory, and resolves it to the volume root (e.g. L"C:\"). + * The result always carries a trailing backslash. + * + * Use convert_if_required() to avoid the automatic addition of \\?\ + * made by convert() that is propagated in the resulting volume_root + */ + if (!GetVolumePathNameW(convert_if_required(conv_buf_vol, dir), volume_root, PATH_MAX)) { + windows_errno(GetLastError()); + log_error(errno, "Error getting VolumeRoot from '%s'. %s.\n", dir, strerror(errno)); + return -1; + } + + if (!GetVolumeInformationW(volume_root, 0, 0, 0, 0, 0, fs_name, 32)) { + windows_errno(GetLastError()); + log_error(errno, "Error getting information of VolumeRoot '%s'. %s.\n", u16tou8(conv_buf_root, volume_root), strerror(errno)); + return -1; + } + if (wcscmp(fs_name, L"NTFS") == 0) + magic = WINDOWS_NTFS_MAGIC; + else + return -1; /* support only NTFS */ + + /* + * Obtain the canonical volume name: \\?\Volume{GUID}\ + * + * GetVolumePathNameW() may return a drive letter path ("C:\") or a + * directory mount point ("D:\Mount\"). Neither is guaranteed to be + * accepted by Win32_ShadowCopy.Create() on volumes without a drive + * letter. GetVolumeNameForVolumeMountPointW() always returns the + * stable kernel-form GUID path that VSS requires, regardless of how + * many (or how few) mount points the volume has. + * + * We store it in fss->dataset. + */ + if (!GetVolumeNameForVolumeMountPointW(volume_root, volume_name, PATH_MAX)) { + windows_errno(GetLastError()); + log_error(errno, "Error getting VolumeName of VolumeRoot '%s'. %s.\n", u16tou8(conv_buf_root, volume_root), strerror(errno)); + return -1; + } + + /* don't use pathimport for dataset to keep backslashes */ + pathcpy(fss->dataset, sizeof(fss->dataset), u16tou8(conv_buf_root, volume_name)); + + /* use pathimport to convert backslashes to slashes */ + pathimport(fss->root_dir, sizeof(fss->root_dir), u16tou8(conv_buf_root, volume_root)); + + /* the returned root_dir should match the start of the passed dir */ + if (pathncmp(fss->root_dir, dir, strlen(fss->root_dir)) != 0) { + errno = EINVAL; + log_error(errno, "Not matching VolumeRoot '%s' for '%s'.\n", fss->root_dir, dir); + return -1; + } + + pathcpy(fss->snapshot_dir, sizeof(fss->snapshot_dir), fss->root_dir); + pathcat(fss->snapshot_dir, sizeof(fss->snapshot_dir), SNAPSHOT_CONTAINER "/"); + + if (windows_mkdir(fss->snapshot_dir) != 0 && errno != EEXIST) { + log_error(errno, "Error creating directory '%s'. %s.\n", fss->snapshot_dir, strerror(errno)); + return -1; + } + + /* refresh the links, after reboots they gets invalidated */ + if (windows_rebuild_link(fss, SNAPSHOT_PENDING) != 0) { + return -1; + } + + if (windows_rebuild_link(fss, SNAPSHOT_STABLE) != 0) { + return -1; + } + + fss->magic = magic; + + return 0; +} + +int fssnapshot_stat(struct fssnapshot_struct* fss, const char* name, struct stat* st) +{ + char link_path[PATH_MAX]; + + pathcpy(link_path, sizeof(link_path), fss->snapshot_dir); + pathcat(link_path, sizeof(link_path), name); + + /* use lstat because we want to check the link */ + if (windows_lstat(link_path, st) != 0) + return -1; + + /* it must be a link to a directory */ + if (!S_ISLNKDIR(st->st_mode)) + return -1; + + return 0; +} + +int fssnapshot_create(const struct fssnapshot_struct* fss, const char* name) +{ + char cmd[PS_CMD_MAX]; + char device_path[PATH_MAX]; + char target_path[PATH_MAX]; + char link_path[PATH_MAX]; + char guid_link[PATH_MAX]; + char out[PATH_MAX]; + + /* create VSS shadow copy and capture the system-assigned GUID */ + snprintf(cmd, sizeof(cmd), + "$r = (Get-WmiObject -List Win32_ShadowCopy).Create('%s', 'ClientAccessible'); " + "if ($r.ReturnValue -eq 0) { " + "Write-Output ('ID_' + $r.ShadowID); " + "} else { " + "Write-Output ('0x{0:X8}' -f $r.ReturnValue); " + "}", + fss->dataset); + if (windows_ps(cmd, out, sizeof(out)) != 0) { + log_error(errno, "Error creating snapshot of VolumeName '%s'. %s.\n", fss->dataset, strerror(errno)); + return -1; + } + if (strncmp(out, "ID_", 3) != 0) { + errno = ENODATA; + log_error(errno, "VSS snapshot creation of '%s' failed with error %s.\n", fss->dataset, out); + return -1; + } + + const char* guid = out + 3; + + /* resolve the shadow copy's device object path */ + snprintf(cmd, sizeof(cmd), "(Get-WmiObject Win32_ShadowCopy | Where-Object {$_.ID -eq '%s'}).DeviceObject", guid); + if (windows_ps(cmd, device_path, sizeof(device_path)) != 0) { + log_error(errno, "Error getting the snapshot DeviceObject from GUID '%s'. %s.\n", guid, strerror(errno)); + goto bail_and_delete; + } + if (device_path[0] == 0) { + errno = ENODATA; + log_error(errno, "Empty snapshot DeviceObject of GUID '%s'. %s.\n", guid, strerror(errno)); + goto bail_and_delete; + } + + /* use pathimport to convert backslashes to slashes */ + pathimport(target_path, sizeof(target_path), device_path); + + /* the ending slash is required, otherwise the link won't work */ + pathslash(target_path, sizeof(target_path)); + + pathcpy(link_path, sizeof(link_path), fss->snapshot_dir); + pathcat(link_path, sizeof(link_path), name); + + int ret = windows_symlink_directory(target_path, link_path); + if (ret != 0) { + log_error(errno, "Error creating symlink '%s'. %s.\n", link_path, strerror(errno)); + goto bail_and_delete; + } + + pathcpy(guid_link, sizeof(guid_link), fss->snapshot_dir); + pathcat(guid_link, sizeof(guid_link), name); + pathcat(guid_link, sizeof(guid_link), SNAPSHOT_GUID); + + /* store GUID persistently as symlink target */ + ret = windows_symlink_directory(guid, guid_link); + if (ret != 0) { + log_error(errno, "Error creating GUID symlink '%s'. %s.\n", guid_link, strerror(errno)); + windows_rmdir(link_path); + goto bail_and_delete; + } + + return 0; + +bail_and_delete: + /* destroy the shadow copy we just created */ + snprintf(cmd, sizeof(cmd), "Get-WmiObject Win32_ShadowCopy | Where-Object {$_.ID -eq '%s'} | Remove-WmiObject", guid); + windows_ps(cmd, 0, 0); + return -1; +} + +int fssnapshot_delete(const struct fssnapshot_struct* fss, const char* name) +{ + char link_path[PATH_MAX]; + char guid[PATH_MAX]; + char cmd[PS_CMD_MAX]; + char guid_link[PATH_MAX]; + + pathcpy(guid_link, sizeof(guid_link), fss->snapshot_dir); + pathcat(guid_link, sizeof(guid_link), name); + pathcat(guid_link, sizeof(guid_link), SNAPSHOT_GUID); + + pathcpy(link_path, sizeof(link_path), fss->snapshot_dir); + pathcat(link_path, sizeof(link_path), name); + + if (windows_read_guid(guid_link, guid, sizeof(guid)) != 0) + return -1; + + /* if the GUID link is gone, assume everything is gone */ + if (guid[0] == 0) { + /* remove as not valid anymore */ + windows_rmdir(link_path); + return 0; + } + + /* destroy the VSS shadow copy */ + snprintf(cmd, sizeof(cmd), "Get-WmiObject Win32_ShadowCopy | Where-Object {$_.ID -eq '%s'} | Remove-WmiObject", guid); + int ret = windows_ps(cmd, 0, 0); + if (ret != 0) { + log_error(errno, "Error destroy the snapshot from GUID '%s'. %s.\n", guid, strerror(errno)); + /* remove as not valid anymore */ + windows_rmdir(link_path); + return -1; + } + + /* remove live symlink if present */ + windows_rmdir(link_path); + + /* remove persistent GUID symlink */ + if (windows_rmdir(guid_link) != 0) { + log_error(errno, "Error rmdir '%s'. %s.\n", guid_link, strerror(errno)); + return -1; + } + + return 0; +} + +int fssnapshot_rename(const struct fssnapshot_struct* fss, const char* old_name, const char* new_name) +{ + char old_link[PATH_MAX]; + char new_link[PATH_MAX]; + char old_guid[PATH_MAX]; + char new_guid[PATH_MAX]; + + pathcpy(old_link, sizeof(old_link), fss->snapshot_dir); + pathcat(old_link, sizeof(old_link), old_name); + pathcpy(new_link, sizeof(new_link), fss->snapshot_dir); + pathcat(new_link, sizeof(new_link), new_name); + + pathcpy(old_guid, sizeof(old_guid), fss->snapshot_dir); + pathcat(old_guid, sizeof(old_guid), old_name); + pathcat(old_guid, sizeof(old_guid), SNAPSHOT_GUID); + pathcpy(new_guid, sizeof(new_guid), fss->snapshot_dir); + pathcat(new_guid, sizeof(new_guid), new_name); + pathcat(new_guid, sizeof(new_guid), SNAPSHOT_GUID); + + if (windows_rename(old_link, new_link) != 0) { + log_error(errno, "Error renaming '%s' to '%s'. %s.\n", old_link, new_link, strerror(errno)); + return -1; + } + + if (windows_rename(old_guid, new_guid) != 0) { + log_error(errno, "Error renaming '%s' to '%s'. %s.\n", old_guid, new_guid, strerror(errno)); + /* try to restore */ + windows_rename(new_link, old_link); + return -1; + } + + return 0; +} + uint64_t os_tick(void) { LARGE_INTEGER t; diff --git a/cmdline/portable.h b/cmdline/portable.h index 3e90f5a2..8fb715d1 100644 --- a/cmdline/portable.h +++ b/cmdline/portable.h @@ -363,6 +363,17 @@ int filephy(const char* path, uint64_t size, uint64_t* physical); */ int fsinfo(const char* path, int* has_persistent_inode, int* has_syncronized_hardlinks, uint64_t* total_space, uint64_t* free_space, char* fstype, size_t fstype_size, char* fslabel, size_t fslabel_size); +/** + * Where snapshot are accessible + */ +#define SNAPSHOT_CONTAINER ".snapraid" + +/** + * Snapshots names + */ +#define SNAPSHOT_PENDING "pending" +#define SNAPSHOT_STABLE "stable" + /** * Snapshots context */ @@ -390,7 +401,8 @@ struct fssnapshot_struct { char snapshot_dir[PATH_MAX]; /* - * Dataset name (ZFS only, e.g. "pool/data"). + * Dataset name for ZFS (e.g. "pool/data") + * or VolumeName in Windows. * Empty string for filesystems where not applicable. */ char dataset[PATH_MAX]; diff --git a/cmdline/state.c b/cmdline/state.c index 481475a4..e5617cfe 100644 --- a/cmdline/state.c +++ b/cmdline/state.c @@ -5810,9 +5810,6 @@ void state_load_ignore_file(tommy_list* filter_list, const char* path, const cha sclose(f); } -#define SNAPSHOT_PENDING "pending" -#define SNAPSHOT_STABLE "stable" - static int state_snapshot_dir(struct fssnapshot_struct* fss, const char* name, const char* dir, struct snapraid_disk* disk) { struct stat st; diff --git a/doc/snapraid.1 b/doc/snapraid.1 index 6999f119..b8e260b2 100644 --- a/doc/snapraid.1 +++ b/doc/snapraid.1 @@ -1435,8 +1435,9 @@ start of a \'sync\'. This ensures a consistent, point\-in\-time view of your files, preventing errors caused by concurrent file modifications. .PP This option applies exclusively to data disks formatted with the -Btrfs, Bcachefs or ZFS filesystems. Parity disks, or data disks using -other filesystems, will always use the live version of the filesystem. +Btrfs, Bcachefs or ZFS filesystems in Linux and NTFS in Windows. +Parity disks, or data disks using other filesystems, will always use +the live version of the filesystem. .PP This significantly improves recovery: if a file is deleted from the live filesystem, it remains preserved in the snapshot. This prevents the parity @@ -1831,7 +1832,8 @@ Snapshots are created only for data disks and only if the underlying filesystem supports this functionality. Parity disks always use the live filesystem. .PP -At present, this is supported exclusively on Btrfs, Bcachefs, and ZFS. +At present, this is supported on Btrfs, Bcachefs, and ZFS in Linux +and on NTFS in Windows. .PP You can mix data disks with different filesystems. Only those that support snapshots will utilize them, while other disks will continue diff --git a/doc/snapraid.d b/doc/snapraid.d index f028a2b4..5dc7927f 100644 --- a/doc/snapraid.d +++ b/doc/snapraid.d @@ -1155,8 +1155,9 @@ Configuration files, preventing errors caused by concurrent file modifications. This option applies exclusively to data disks formatted with the - Btrfs, Bcachefs or ZFS filesystems. Parity disks, or data disks using - other filesystems, will always use the live version of the filesystem. + Btrfs, Bcachefs or ZFS filesystems in Linux and NTFS in Windows. + Parity disks, or data disks using other filesystems, will always use + the live version of the filesystem. This significantly improves recovery: if a file is deleted from the live filesystem, it remains preserved in the snapshot. This prevents the parity @@ -1443,7 +1444,8 @@ Snapshots filesystem supports this functionality. Parity disks always use the live filesystem. - At present, this is supported exclusively on Btrfs, Bcachefs, and ZFS. + At present, this is supported on Btrfs, Bcachefs, and ZFS in Linux + and on NTFS in Windows. You can mix data disks with different filesystems. Only those that support snapshots will utilize them, while other disks will continue diff --git a/doc/snapraid.txt b/doc/snapraid.txt index fe26b037..043a85c8 100644 --- a/doc/snapraid.txt +++ b/doc/snapraid.txt @@ -1233,8 +1233,9 @@ start of a 'sync'. This ensures a consistent, point-in-time view of your files, preventing errors caused by concurrent file modifications. This option applies exclusively to data disks formatted with the -Btrfs, Bcachefs or ZFS filesystems. Parity disks, or data disks using -other filesystems, will always use the live version of the filesystem. +Btrfs, Bcachefs or ZFS filesystems in Linux and NTFS in Windows. +Parity disks, or data disks using other filesystems, will always use +the live version of the filesystem. This significantly improves recovery: if a file is deleted from the live filesystem, it remains preserved in the snapshot. This prevents the parity @@ -1548,7 +1549,8 @@ Snapshots are created only for data disks and only if the underlying filesystem supports this functionality. Parity disks always use the live filesystem. -At present, this is supported exclusively on Btrfs, Bcachefs, and ZFS. +At present, this is supported on Btrfs, Bcachefs, and ZFS in Linux +and on NTFS in Windows. You can mix data disks with different filesystems. Only those that support snapshots will utilize them, while other disks will continue diff --git a/snapraid.conf.example.windows b/snapraid.conf.example.windows index 57d758bd..2bc7179d 100644 --- a/snapraid.conf.example.windows +++ b/snapraid.conf.example.windows @@ -44,6 +44,34 @@ data d3 M:\array\ # Format: "other DISK_NAME DISK_MOUNT_POINT" extra boot C:\ +# Enable the use of filesystem snapshots for sync, scrub, check, and fix +# commands. +# +# When enabled, SnapRAID creates a read-only snapshot of your data at the +# start of a 'sync'. This ensures a consistent, point-in-time view of your +# files, preventing errors caused by concurrent file modifications. +# +# This option applies exclusively to data disks formatted with the +# NTFS filesystem. Parity disks, or data disks using other filesystems, +# will always use the live version of the filesystem. +# +# This significantly improves recovery: if a file is deleted from the live +# filesystem, it remains preserved in the snapshot. This prevents the parity +# from becoming "broken" for that block, ensuring you can still successfully +# recover data if another disk fails. +# +# Snapshots are stored in a hidden '.snapraid' directory at the root +# of the subvolume containing your data. SnapRAID will automatically +# detect the subvolume root for each data path. +# +# Snapshot creation and deletion require administrative privileges. +# Ensure SnapRAID is run with the necessary permissions when this option +# is enabled. +# +# See the 'Snapshots' section in the snapraid.txt documentation for a +# detailed explanation of the snapshot lifecycle. +#snapshot + # Excludes hidden files and directories (uncomment to enable). #nohidden