289 lines
10 KiB
C#
289 lines
10 KiB
C#
using Microsoft.Win32;
|
|
using System.Security;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace Zcbot.WindowsNode;
|
|
|
|
internal sealed record NodeDataRootSelection(
|
|
string RootDirectory,
|
|
bool IsEnvironmentManaged,
|
|
string Source);
|
|
|
|
internal sealed record NodeDataMigrationResult(
|
|
string SourceDirectory,
|
|
string TargetDirectory,
|
|
int FileCount,
|
|
long TotalBytes);
|
|
|
|
internal static class NodeDataRootSettings
|
|
{
|
|
internal const string EnvironmentVariableName = "ZCBOT_WINDOWS_NODE_DATA_DIR";
|
|
internal const string RegistryPath = @"Software\Zcbot\WindowsNode";
|
|
internal const string RegistryValueName = "DataRoot";
|
|
|
|
internal static NodeDataRootSelection Resolve()
|
|
{
|
|
var managed = Environment.GetEnvironmentVariable(EnvironmentVariableName)?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(managed))
|
|
{
|
|
return new NodeDataRootSelection(
|
|
Normalize(managed), IsEnvironmentManaged: true, "environment");
|
|
}
|
|
|
|
using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: false);
|
|
var configured = (key?.GetValue(RegistryValueName) as string)?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(configured))
|
|
{
|
|
return new NodeDataRootSelection(
|
|
Normalize(configured), IsEnvironmentManaged: false, "user");
|
|
}
|
|
|
|
return new NodeDataRootSelection(DefaultRoot(), IsEnvironmentManaged: false, "default");
|
|
}
|
|
|
|
internal static void SaveUserRoot(string root)
|
|
{
|
|
var normalized = Normalize(root);
|
|
using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true);
|
|
key.SetValue(RegistryValueName, normalized, RegistryValueKind.String);
|
|
}
|
|
|
|
internal static string Normalize(string root)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root))
|
|
{
|
|
throw new NodeConfigurationException("Data root must be an absolute local path.");
|
|
}
|
|
var normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root.Trim()));
|
|
if (normalized.StartsWith(@"\\", StringComparison.Ordinal))
|
|
{
|
|
throw new NodeConfigurationException("Data root must be on a local drive.");
|
|
}
|
|
if (string.Equals(normalized, Path.GetPathRoot(normalized), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new NodeConfigurationException("Data root cannot be the root of a drive.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private static string DefaultRoot() => Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"Zcbot", "WindowsNode");
|
|
}
|
|
|
|
internal static class NodeDataMigrator
|
|
{
|
|
internal static Task<NodeDataMigrationResult> MigrateAsync(
|
|
string sourceRoot,
|
|
string targetRoot,
|
|
CancellationToken cancellationToken) => Task.Run(
|
|
() => Migrate(sourceRoot, targetRoot, cancellationToken), cancellationToken);
|
|
|
|
private static NodeDataMigrationResult Migrate(
|
|
string sourceRoot,
|
|
string targetRoot,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var source = NodeDataRootSettings.Normalize(sourceRoot);
|
|
var target = NodeDataRootSettings.Normalize(targetRoot);
|
|
if (string.Equals(source, target, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new NodeConfigurationException("The new data root is the same as the current data root.");
|
|
}
|
|
EnsureSeparateTrees(source, target);
|
|
var targetDrive = EnsureLocalFixedDrive(target);
|
|
|
|
if (Directory.Exists(target) && Directory.EnumerateFileSystemEntries(target).Any())
|
|
{
|
|
throw new NodeConfigurationException("The selected data root must be empty.");
|
|
}
|
|
if (File.Exists(target))
|
|
{
|
|
throw new NodeConfigurationException("The selected data root is an existing file.");
|
|
}
|
|
|
|
var requiredBytes = Directory.Exists(source)
|
|
? EnumerateFiles(source)
|
|
.Sum(path => new FileInfo(path).Length)
|
|
: 0L;
|
|
if (targetDrive.AvailableFreeSpace < requiredBytes)
|
|
{
|
|
throw new NodeConfigurationException(
|
|
$"The target drive needs at least {requiredBytes} free bytes for migration.");
|
|
}
|
|
|
|
var parent = Directory.GetParent(target)?.FullName
|
|
?? throw new NodeConfigurationException("The selected data root has no parent directory.");
|
|
Directory.CreateDirectory(parent);
|
|
var staging = Path.Combine(parent, $".{Path.GetFileName(target)}.migration-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(staging);
|
|
NodeConfigStore.RestrictDirectory(staging);
|
|
|
|
try
|
|
{
|
|
var copied = Directory.Exists(source)
|
|
? CopyTree(source, staging, cancellationToken)
|
|
: (FileCount: 0, TotalBytes: 0L);
|
|
if (Directory.Exists(source))
|
|
{
|
|
VerifyTree(source, staging, cancellationToken);
|
|
}
|
|
if (Directory.Exists(target))
|
|
{
|
|
Directory.Delete(target, recursive: false);
|
|
}
|
|
Directory.Move(staging, target);
|
|
return new NodeDataMigrationResult(source, target, copied.FileCount, copied.TotalBytes);
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(staging))
|
|
{
|
|
Directory.Delete(staging, recursive: true);
|
|
}
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static (int FileCount, long TotalBytes) CopyTree(
|
|
string source,
|
|
string target,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var fileCount = 0;
|
|
long totalBytes = 0;
|
|
foreach (var directory in EnumerateDirectories(source))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
Directory.CreateDirectory(Path.Combine(target, Path.GetRelativePath(source, directory)));
|
|
}
|
|
foreach (var file in EnumerateFiles(source))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var destination = Path.Combine(target, Path.GetRelativePath(source, file));
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
File.Copy(file, destination, overwrite: false);
|
|
var length = new FileInfo(file).Length;
|
|
fileCount++;
|
|
totalBytes += length;
|
|
}
|
|
return (fileCount, totalBytes);
|
|
}
|
|
|
|
private static void VerifyTree(
|
|
string source,
|
|
string target,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var sourceFiles = EnumerateFiles(source)
|
|
.Select(path => Path.GetRelativePath(source, path))
|
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var targetFiles = EnumerateFiles(target)
|
|
.Select(path => Path.GetRelativePath(target, path))
|
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
if (!sourceFiles.SequenceEqual(targetFiles, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
throw new IOException("Data root migration verification found a different file set.");
|
|
}
|
|
foreach (var relativePath in sourceFiles)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var sourcePath = Path.Combine(source, relativePath);
|
|
var targetPath = Path.Combine(target, relativePath);
|
|
if (new FileInfo(sourcePath).Length != new FileInfo(targetPath).Length
|
|
|| !HashFile(sourcePath).SequenceEqual(HashFile(targetPath)))
|
|
{
|
|
throw new IOException($"Data root migration verification failed: {relativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] HashFile(string path)
|
|
{
|
|
using var stream = new FileStream(
|
|
path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024,
|
|
FileOptions.SequentialScan);
|
|
return SHA256.HashData(stream);
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateDirectories(string root)
|
|
{
|
|
var pending = new Stack<string>();
|
|
pending.Push(root);
|
|
while (pending.Count > 0)
|
|
{
|
|
var current = pending.Pop();
|
|
foreach (var directory in Directory.EnumerateDirectories(
|
|
current, "*", SearchOption.TopDirectoryOnly))
|
|
{
|
|
RejectReparsePoint(directory);
|
|
pending.Push(directory);
|
|
yield return directory;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateFiles(string root)
|
|
{
|
|
RejectReparsePoint(root);
|
|
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.TopDirectoryOnly))
|
|
{
|
|
RejectReparsePoint(file);
|
|
yield return file;
|
|
}
|
|
foreach (var directory in EnumerateDirectories(root))
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(
|
|
directory, "*", SearchOption.TopDirectoryOnly))
|
|
{
|
|
RejectReparsePoint(file);
|
|
yield return file;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void EnsureSeparateTrees(string source, string target)
|
|
{
|
|
var separator = Path.DirectorySeparatorChar.ToString();
|
|
var sourcePrefix = source + separator;
|
|
var targetPrefix = target + separator;
|
|
if (target.StartsWith(sourcePrefix, StringComparison.OrdinalIgnoreCase)
|
|
|| source.StartsWith(targetPrefix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new NodeConfigurationException(
|
|
"The old and new data roots cannot contain one another.");
|
|
}
|
|
}
|
|
|
|
private static DriveInfo EnsureLocalFixedDrive(string target)
|
|
{
|
|
var root = Path.GetPathRoot(target)
|
|
?? throw new NodeConfigurationException("The selected data root has no drive.");
|
|
var drive = new DriveInfo(root);
|
|
if (!drive.IsReady || drive.DriveType != DriveType.Fixed)
|
|
{
|
|
throw new NodeConfigurationException("Data root must be on a fixed local drive.");
|
|
}
|
|
return drive;
|
|
}
|
|
|
|
private static void RejectReparsePoint(string path)
|
|
{
|
|
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
|
{
|
|
throw new IOException($"Data root migration does not follow links: {path}");
|
|
}
|
|
}
|
|
}
|