duplicati/Duplicati/UnitTest/TestHyperVParser.cs
2025-09-04 20:14:52 +02:00

397 lines
No EOL
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Linq;
using Duplicati.Library.Logging;
using Duplicati.Library.Snapshots;
using Duplicati.Library.Snapshots.Windows;
using NUnit.Framework;
using NUnit.Framework.Legacy;
namespace Duplicati.UnitTest
{
[TestFixture]
public class TestHyperVParser
{
private const char PS = ';'; // Windows path separator
private const char DS = '\\'; // Directory separator
private sealed record MachineEntry(string ID, string Name, string[] Paths);
private sealed class MockHyperVUtility : IHyperVUtility
{
public bool IsHyperVInstalled { get; set; } = true;
public Guid HyperVWriterGuid { get; set; } = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
public bool IsVSSWriterSupported { get; set; } = true;
public List<HyperVGuest> Guests { get; set; } = null;
public void QueryHyperVGuestsInfo(WindowsSnapshotProvider provider, bool bIncludePaths = false)
{
// Mock implementation, no actual querying
}
}
private sealed class LogSink : ILogDestination
{
public List<LogEntry> Entries { get; } = [];
public void WriteMessage(LogEntry entry)
{
Entries.Add(entry);
}
}
private static readonly string HyperVPathTemplate = $"%HYPERV%{DS}{{0}}";
private static string MakeHyperVSource(Guid id) => string.Format(HyperVPathTemplate, id);
[Test]
public void Returns_empty_change_set_when_HyperV_not_installed()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var paths = new[] { @"C:\data" };
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var utility = new MockHyperVUtility { IsHyperVInstalled = false };
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var result = module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.That(result, Is.Empty, "No cmdline option should be rewritten.");
Assert.That(paths, Is.EqualTo(new[] { @"C:\data" }), "Source paths must stay untouched.");
}
[Test]
public void Removes_HyperV_writer_from_vss_exclude_list()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var hyperVGuid = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var paths = new[] { string.Format(HyperVPathTemplate, Guid.NewGuid()) };
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["vss-exclude-writers"] = $"{hyperVGuid};bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
["snapshot-policy"] = "required"
};
var utility = new MockHyperVUtility { HyperVWriterGuid = hyperVGuid };
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var result = module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.Multiple(() =>
{
Assert.That(result.ContainsKey("vss-exclude-writers"), Is.True,
"The change-set must contain the corrected option.");
Assert.That(result["vss-exclude-writers"],
Does.Not.Contain(hyperVGuid.ToString()),
"The Hyper-V writer GUID must have been stripped.");
});
}
[Test]
public void Adds_required_snapshot_policy_when_missing()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var guestId = Guid.NewGuid();
var paths = new[] { MakeHyperVSource(guestId) };
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var utility = new MockHyperVUtility
{
Guests = new List<HyperVGuest>
{
new("TestVM", guestId, [@"C:\VMs\TestVM\TestVM.vhdx"])
}
};
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
var result = module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.Multiple(() =>
{
Assert.That(result.ContainsKey("snapshot-policy"), Is.True);
Assert.That(result["snapshot-policy"], Is.EqualTo("required"),
"Snapshot policy must be enforced to 'required'.");
});
}
[Test]
public void Expands_guest_data_paths_into_source_list()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var guestId = Guid.NewGuid();
var guest = new HyperVGuest("TestVM", guestId, [@"C:\HyperV\TestVM\TestVM.vhdx", @"C:\HyperV\TestVM\State.bin"]);
var paths = new[] { MakeHyperVSource(guestId) };
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["snapshot-policy"] = "required"
};
var utility = new MockHyperVUtility
{
Guests = new List<HyperVGuest> { guest }
};
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.That(paths, Does.Contain(guest.DataPaths[0]));
Assert.That(paths, Does.Contain(guest.DataPaths[1]));
}
[Test]
public void Respects_exclude_filter_expressions()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var guestId = Guid.NewGuid();
var data = @"C:\HyperV\TestVM\TestVM.vhdx";
var guest = new HyperVGuest("TestVM", guestId, [data]);
var paths = new[] { MakeHyperVSource(guestId) };
var filter = $"-{data}"; // exclude the only path
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["snapshot-policy"] = "required"
};
var utility = new MockHyperVUtility
{
Guests = new List<HyperVGuest> { guest }
};
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.That(paths, Does.Not.Contain(data),
"The excluded VHDX must not make it into the final path list.");
}
[Test]
public void Respects_exclude_filter_expressions_hyperv()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var guestId = Guid.NewGuid();
var data = @$"C:{DS}HyperV{DS}TestVM{DS}TestVM.vhdx";
var guest = new HyperVGuest("TestVM", guestId, [data]);
var paths = new[] { MakeHyperVSource(guestId) };
var filter = $"-{MakeHyperVSource(guestId)}{DS}{data}"; // exclude the only path
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["snapshot-policy"] = "required"
};
var utility = new MockHyperVUtility
{
Guests = new List<HyperVGuest> { guest }
};
using var log = Duplicati.Library.Logging.Log.StartScope(new LogSink());
var module = new Duplicati.Library.Modules.Builtin.HyperVOptions();
module.RealParseSourcePaths(ref paths, ref filter, options, utility);
Assert.That(paths, Does.Not.Contain(data),
"The excluded VHDX must not make it into the final path list.");
}
[Test]
public void HyperV_all_marker_includes_every_machine()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
// arrange
var g1 = new HyperVGuest("Alpha", Guid.NewGuid(),
[@$"C:{DS}HyperV{DS}Alpha{DS}Alpha.vhdx"]);
var g2 = new HyperVGuest("Beta", Guid.NewGuid(),
[@$"D:{DS}VMs{DS}Beta{DS}Disk1.vhdx", @$"D:{DS}VMs{DS}Beta{DS}State.bin"]);
var paths = new[] { "%HYPERV%" }; // <-- only the ALL-marker
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { g1, g2 } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
// assert every data-path from every guest must be present
var expected = g1.DataPaths.Concat(g2.DataPaths);
CollectionAssert.IsSubsetOf(expected, paths);
}
[Test]
public void All_marker_but_single_guest_excluded()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var gKeep = new HyperVGuest("KeepMe", Guid.NewGuid(),
[@$"C:{DS}VMs{DS}KeepMe{DS}KeepMe.vhdx"]);
var gDrop = new HyperVGuest("DropMe", Guid.NewGuid(),
[@$"C:{DS}VMs{DS}DropMe{DS}DropMe.vhdx"]);
var paths = new[] { "%HYPERV%" };
var filter = $"-{MakeHyperVSource(gDrop.ID)}";
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { gKeep, gDrop } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
Assert.That(paths, Does.Contain(gKeep.DataPaths.First()), "kept VM missing");
Assert.That(paths, Does.Not.Contain(gDrop.DataPaths.First()), "excluded VM leaked in");
}
[Test]
public void All_marker_but_single_file_excluded()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var g = new HyperVGuest("VM", Guid.NewGuid(),
[@$"C:{DS}VMs{DS}VM{DS}Disk.vhdx",
@$"C:{DS}VMs{DS}VM{DS}State.bin"]);
var cutFile = g.DataPaths[1];
var paths = new[] { "%HYPERV%" };
var filter = $"-{MakeHyperVSource(g.ID)}{DS}{cutFile}";
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { g } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
Assert.That(paths, Does.Contain(g.DataPaths[0]), "unaffected file missing");
Assert.That(paths, Does.Not.Contain(cutFile), "excluded file leaked in");
}
[Test]
public void Include_then_exclude_results_in_inclusion()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var g = new HyperVGuest("VM", Guid.NewGuid(),
[@$"C:{DS}VMs{DS}VM{DS}disk.vhdx"]);
var include = MakeHyperVSource(g.ID);
var filter = $"{include}{PS}-{include}";
var paths = new[] { "%HYPERV%" };
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { g } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
Assert.That(paths, Does.Contain(g.DataPaths.First()),
"first-seen INCLUDE should win over later EXCLUDE");
}
[Test]
public void Exclude_then_include_results_in_exclusion()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var g = new HyperVGuest("VM", Guid.NewGuid(),
[@$"C:{DS}VMs{DS}VM{DS}disk.vhdx"]);
var include = MakeHyperVSource(g.ID);
var filter = $"-{include}{PS}{include}";
var paths = new[] { "%HYPERV%" };
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { g } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
Assert.That(paths, Does.Not.Contain(g.DataPaths.First()),
"first-seen EXCLUDE should win over later INCLUDE");
}
[Test]
public void Direct_guest_source_path_includes_that_guest_only()
{
if (!OperatingSystem.IsWindows())
return; // Hyper-V is only available on Windows
var gWanted = new HyperVGuest("Wanted", Guid.NewGuid(),
[@$"E:{DS}VMs{DS}Wanted{DS}disk.vhdx"]);
var gOther = new HyperVGuest("Skip", Guid.NewGuid(),
[@$"E:{DS}VMs{DS}Skip{DS}disk.vhdx"]);
var paths = new[] { MakeHyperVSource(gWanted.ID) }; // ← only that guest
var filter = string.Empty;
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ ["snapshot-policy"] = "required" };
var util = new MockHyperVUtility { Guests = new List<HyperVGuest> { gWanted, gOther } };
using var _ = Duplicati.Library.Logging.Log.StartScope(new LogSink());
new Duplicati.Library.Modules.Builtin.HyperVOptions()
.RealParseSourcePaths(ref paths, ref filter, options, util);
Assert.That(paths, Does.Contain(gWanted.DataPaths.First()), "wanted VM missing");
Assert.That(paths, Does.Not.Contain(gOther.DataPaths.First()), "unexpected VM leaked in");
}
}
}