using System; using System.Collections.Generic; using System.Diagnostics; using System.Runtime.Versioning; using System.Text.RegularExpressions; using System.Threading.Tasks; #nullable enable namespace Duplicati.Library.Snapshots.Windows; /// /// A shadow copy manager using the wmic commandline tool /// [SupportedOSPlatform("windows")] internal class WmicShadowCopyManager : IDisposable { /// /// The tag used for logging messages /// private static readonly string LOGTAG = Logging.Log.LogTagFromType(); /// /// A single shadow copy /// /// The shadow ID as a string /// The shadow ID as a GUID /// The drive that the snapshot is for /// The path that contains the snapshot public class WmicShadowCopy(string shadowId, Guid parsedId, string originalDrive, string mappedPath) : IDisposable { /// /// Gets the shadow ID /// public string ShadowID { get; } = shadowId; /// /// Gets the shadow ID /// public Guid ParsedId { get; } = parsedId; /// /// Gets the drive that was originally mapped /// public string OriginalDrive { get; } = originalDrive; /// /// Gets the path where the snapshot is found /// public string MappedPath { get; } = mappedPath; /// /// Flag keeping track of the snapshot deletion state /// private bool _snapshotDeleted; /// public void Dispose() { DeleteShadowCopy(); } /// /// Deletes the shadow copy /// private void DeleteShadowCopy() { if (_snapshotDeleted) return; if (!string.IsNullOrEmpty(ShadowID)) { _snapshotDeleted = true; Logging.Log.WriteVerboseMessage(LOGTAG, "DeleteShadowCopy", $"Deleting Shadow Copy: {ShadowID}"); DeleteShadow(ShadowID); } } } /// /// The list of the currently registered shadow copies /// private List _shadowCopies = new List(); /// /// Gets the list of the currently registered shadow copies /// public IEnumerable ShadowCopies => _shadowCopies; /// /// Creates a new snapshot for the given drive /// /// The drive to create the snapshot for /// The created snapshot public WmicShadowCopy Add(string drive) { var shadowId = CreateShadowCopy(drive); if (string.IsNullOrEmpty(shadowId)) throw new InvalidOperationException("Failed to create shadow copy"); var shadowPath = GetShadowPath(shadowId); if (string.IsNullOrEmpty(shadowPath)) { DeleteShadow(shadowId); throw new InvalidOperationException("Failed to get shadow copy path"); } var snapshot = new WmicShadowCopy(shadowId, Guid.Parse(shadowId), drive, shadowPath); _shadowCopies.Add(snapshot); return snapshot; } /// public void Dispose() { foreach (var shadow in ShadowCopies) { shadow.Dispose(); } _shadowCopies.Clear(); } /// /// Creates a shadow copy /// /// The drive to create the snapshot for /// The shadow id private static string? CreateShadowCopy(string drive) { string output = ExecuteCommand("wmic", $"shadowcopy call create Volume='{drive}'", 10000); // Extract ShadowID using regex Match match = Regex.Match(output, @"ShadowID\s*=\s*""({[0-9A-F\-]+})"""); if (match.Success) { return match.Groups[1].Value; } Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to create shadow copy for {0}: {1}", drive, output); return null; } /// /// Gets the path where the shadow copy is mounted /// /// The shadow copy id /// The path where the copy is mounted private static string? GetShadowPath(string shadowId) { string output = ExecuteCommand("wmic", "shadowcopy get ID, DeviceObject", 5000); // Extract DeviceObject corresponding to the ShadowID string pattern = $@"(\\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy\d+)\s+{shadowId}"; Match match = Regex.Match(output, pattern); if (match.Success) { return match.Groups[1].Value; } Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to get shadow copy path for {0}: {1}", shadowId, output); return null; } /// /// Returns the drives that are vss enabled /// /// public static HashSet GetVssCapableDrivesViaVssadmin() { var vssDrives = new HashSet(StringComparer.OrdinalIgnoreCase); try { var output = ExecuteCommand("vssadmin", "list volumes", 5000); // Regex to match drive letters in output var matches = Regex.Matches(output, @"Volume\s+path:\s*([A-Z]:)\\?\s", RegexOptions.IgnoreCase); foreach (Match match in matches) vssDrives.Add(match.Groups[1].Value.Substring(0, 1)); } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyListFailed", ex, "Failed to list volumes"); } return vssDrives; } /// /// Delete a shadow copy /// /// The shadow id private static void DeleteShadow(string shadowId) => ExecuteCommand("wmic", $"shadowcopy where ID=\"{shadowId}\" delete", 5000); /// /// Executes a command /// /// The binary to execute /// The arguments to use /// The timeout /// The output of the command private static string ExecuteCommand(string fileName, string arguments, int timeoutMs) { try { using (var process = new Process()) { process.StartInfo.FileName = fileName; process.StartInfo.Arguments = arguments; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; process.StartInfo.UseShellExecute = false; process.StartInfo.CreateNoWindow = true; process.Start(); var outputTask = process.StandardOutput.ReadToEndAsync(); var errorTask = process.StandardError.ReadToEndAsync(); if (!process.WaitForExit(timeoutMs)) { process.Kill(); throw new TimeoutException($"Command '{fileName} {arguments}' timed out after {timeoutMs}ms."); } var output = outputTask.Result; var error = errorTask.Result; if (!string.IsNullOrWhiteSpace(error)) Logging.Log.WriteWarningMessage(LOGTAG, "ShadowCopyFailed", null, "Failed to execute command: {0} {1}: {2}", fileName, arguments, error); return output; } } catch (Exception ex) { Logging.Log.WriteErrorMessage(LOGTAG, "ShadowCopyFailed", ex, "Failed to execute command: {0} {1}", fileName, arguments); return string.Empty; } } }