Files
duplicati/Duplicati/UnitTest/TestHyperVParser.cs
T

397 lines
17 KiB
C#
Raw Normal View History

2025-07-09 14:31:35 +02:00
// 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");
}
}
}