mirror of
https://github.com/duplicati/duplicati.git
synced 2025-11-27 19:10:29 +08:00
397 lines
No EOL
17 KiB
C#
397 lines
No EOL
17 KiB
C#
// 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");
|
||
}
|
||
}
|
||
} |