diff --git a/CrypVol.Cli/Browse/BrowseHelper.cs b/CrypVol.Cli/Browse/BrowseHelper.cs index 4b1b831..8808a05 100644 --- a/CrypVol.Cli/Browse/BrowseHelper.cs +++ b/CrypVol.Cli/Browse/BrowseHelper.cs @@ -1,11 +1,11 @@ using System.CommandLine; using System.Text; using System.Text.Json; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Volume; +using Microsoft.Extensions.Logging; namespace CrypVol.Cli.Browse; @@ -17,15 +17,15 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var fmt = args.GetValue(CommandDefinition.Browse.OutputFormat); var longFormat = args.GetValue(CommandDefinition.Browse.LongFormat); var outputFile = args.GetValue(CommandDefinition.Browse.Output); - var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(BrowseHelper)); // 1. 解析卷文件 + 密钥加载 - var volFiles = VolumeDiscovery.Discover(rawInput, loggerFactory.CreateLogger("VolumeDiscovery")).ToList() + var volFiles = VolumeDiscovery.Discover(rawInput, logger).ToList() .AsReadOnly(); if (volFiles.Count is 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 1; } @@ -33,9 +33,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var keyFile = args.GetValue(CommandDefinition.Browse.KeyFile); if (keyFile is null) { - keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, loggerFactory.CreateLogger("VolumeDiscovery")); + keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, logger); if (keyFile is not null) - Console.WriteLine($"自动发现密钥文件: {keyFile.FullName}"); + logger.LogInformation("自动发现密钥文件: {KeyFileFullName}", keyFile.FullName); } if (keyFile is not null) @@ -44,12 +44,12 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Browse.Password), args.GetValue(CommandDefinition.Browse.PrivkeyKey), - args.GetValue(CommandDefinition.Browse.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Browse.PrivkeyKeyPass), token, logger); creds = cvk.ToCredentials(); } catch (Exception ex) { - Console.Error.WriteLine(ex.Message); + await Console.Error.WriteLineAsync($"无法加载密钥: {ex.Message}"); return 1; } else @@ -58,7 +58,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) // 2. Engine var engine = new CrypVolHelper { - Logger = loggerFactory.CreateLogger("CrypVol") + Logger = Program.LoggerFactory.CreateLogger(nameof(CrypVol)) }; var result = await engine.BrowseAsync(new BrowseOptions { @@ -69,7 +69,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) }, token); if (!result.Success) { - Console.Error.WriteLine($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"错误: {result.Error}"); return 1; } @@ -84,7 +84,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法写入输出文件:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法写入输出文件: {ex.Message}"); return 1; } else @@ -141,7 +141,6 @@ private static string FormatTable(List files, int volumeCount, foreach (var f in files) { var size = Fmt(f.Size); - var vols = string.Join(",", f.Volumes); var cross = f.FragmentCount > 1 ? $"是 ({string.Join("->", f.Volumes)})" : "-"; var status = f.IsComplete ? "" : "不完整"; sb.AppendLine($"{size,-10} {f.FragmentCount,-6} {cross,-16} {status,-8} {f.Path}"); @@ -216,4 +215,4 @@ private static string Fmt(long b) < 1073741824 => $"{b / 1048576.0:F1}M", _ => $"{b / 1073741824.0:F2}G" }; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/CommandDefinition.cs b/CrypVol.Cli/CommandDefinition.cs index 3678d53..048fdec 100644 --- a/CrypVol.Cli/CommandDefinition.cs +++ b/CrypVol.Cli/CommandDefinition.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.IO.Compression; using CrypVol.Cli.Browse; using CrypVol.Cli.Convert; using CrypVol.Cli.Extract; @@ -8,7 +9,8 @@ using CrypVol.Cli.Rekey; using CrypVol.Cli.Repair; using CrypVol.Cli.Verify; -using CrypVol.Lib; +using CrypVol.Lib.Crypto; +using CrypVol.Lib.Volume; using Microsoft.Extensions.Logging; namespace CrypVol.Cli; @@ -90,8 +92,8 @@ public static class Pack public static readonly Option> PublicKey; public static readonly Option PrivkeyKey; public static readonly Option PrivkeyKeyPass; - public static readonly Option Compress; - public static readonly Option CompressionLevel; + public static readonly Option CompressionLevel; + public static readonly Option NoCompress; public static readonly Option KeyOutputPath; public static readonly Option Include; public static readonly Option Exclude; @@ -163,7 +165,7 @@ static Pack() }; // ── 加密选项:两种模式互斥 ── - // A) 指定 --key-file → 复用已有 .cvk 的 CEK(--mode/--password/--public-key 忽略) + // A) 指定 --key-file → 复用已有 .cvk 的 CEK(--mode/--public-key 忽略) // B) 不指定 → 生成新 CEK + 新 .cvk(--mode 等必需) KeyFile = new Option("--key-file", "-k") @@ -171,7 +173,8 @@ static Pack() Description = """ 指定已有 .cvk 密钥文件,直接使用其 CEK 加密数据。 - 提供此选项时,--mode / --password / --public-key 均被忽略。 + Password 模式的 CVK 仍须通过 --password 解封;Asymmetric 模式会自动尝试 ~/.ssh 中的私钥,也可通过 --privkey-key 指定。 + 提供此选项时,--mode / --public-key / --comment 不参与新密钥创建。 不提供时,需通过 --mode 等选项配置新密钥的生成方式。 """, HelpName = "cvk-file" @@ -217,7 +220,7 @@ static Pack() { Description = """ - RSA/ECC 公钥文件(PEM 格式)。仅在未指定 --key-file 且 --mode Asymmetric 时生效。 + RSA 公钥文件(PEM 或 OpenSSH ssh-rsa 格式)。仅在未指定 --key-file 且 --mode Asymmetric 时生效。 可多次指定以支持多个接收者。 KeyID 默认为文件名(不含扩展名),用于标识密钥。 """, @@ -225,21 +228,22 @@ static Pack() }.AcceptExistingOnly(); // ── 压缩选项 ── - Compress = new Option("--compress", "-c") + + NoCompress = new Option("--no-compress") { - Description = "启用 GZip 数据压缩,减小卷文件体积(会略微增加 CPU 开销)" + Description = "禁用 GZip 数据压缩" }; - CompressionLevel = new Option("--compression-level") + CompressionLevel = new Option("--compression-level") { Description = """ - GZip 压缩级别:0 = 仅存储不压缩,9 = 最高压缩比。 - 仅在启用 --compress 时生效。 + GZip 压缩预设:Fastest、Optimal、SmallestSize。 + 默认 Optimal;仅在未指定 --no-compress 时生效。 """, - HelpName = "0-9", - DefaultValueFactory = static _ => 6 - }.AcceptOnlyFromAmong("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"); + HelpName = "preset", + DefaultValueFactory = static _ => System.IO.Compression.CompressionLevel.Optimal + }; // ── 密钥输出 ── KeyOutputPath = new Option("--key-output") @@ -271,6 +275,8 @@ static Pack() None —— 不嵌入校验数据 Block —— 每个数据块附带 CRC32(默认),可检测磁盘静默损坏 + File —— 每个文件附带 SHA-256 校验值 + Volume —— 每个卷附带 SHA-256 校验值(含块级校验) """, DefaultValueFactory = static _ => IntegrityLevel.Block }; @@ -317,7 +323,7 @@ crypvol pack ./archive --dry-run PublicKey, PrivkeyKey, PrivkeyKeyPass, - Compress, + NoCompress, CompressionLevel, KeyOutputPath, Include, @@ -391,7 +397,7 @@ static Extract() PrivkeyKey = new Option("--privkey-key") { - Description = "RSA/ECC 私钥文件路径(Asymmetric 模式必需)", + Description = "RSA 私钥文件路径(Asymmetric 模式会自动尝试 ~/.ssh;指定后优先使用)", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -510,7 +516,7 @@ static Browse() PrivkeyKey = new Option("--privkey-key") { - Description = "RSA/ECC 私钥文件路径", + Description = "RSA 私钥文件路径", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -606,13 +612,13 @@ static Info() Password = new Option("--password", "-p") { - Description = "密钥文件解密密码。提供后将尝试验证密码并显示 CEK 指纹等额外信息", + Description = "密钥文件解密密码。成功解封后显示 CEK 指纹、注释等内容", HelpName = "passphrase" }; PrivkeyKey = new Option("--privkey-key") { - Description = "RSA/ECC 私钥文件路径。提供后将尝试验证并显示额外信息", + Description = "RSA 私钥文件路径。提供后将尝试验证并显示额外信息", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -627,8 +633,8 @@ public static Command SubCommand() { var cmd = new Command("info", """ - 验证并显示密钥文件 (.cvk) 的保护模式与 CEK 指纹。 - Password 或 Asymmetric 模式必须提供相应解封凭据。 + 显示密钥文件 (.cvk) 的信封编码模式;成功解封后显示 CEK 指纹、注释和公钥接收者。 + 即使未提供解封凭据也会输出信封模式,但 Password 或 Asymmetric CVK 将以失败退出。 示例: crypvol info ./archive.cvk @@ -691,7 +697,7 @@ static Verify() PrivkeyKey = new Option("--privkey-key") { - Description = "RSA/ECC 私钥文件路径", + Description = "RSA 私钥文件路径", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -828,7 +834,7 @@ static Repair() PrivkeyKey = new Option("--privkey-key") { - Description = "RSA/ECC 私钥文件路径", + Description = "RSA 私钥文件路径", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -955,7 +961,7 @@ static Rekey() PrivkeyKey = new Option("--privkey-key") { - Description = "当前 .cvk 的解密私钥(当前为 Asymmetric 模式时必需)", + Description = "当前 .cvk 的解密私钥(Asymmetric 模式会自动尝试 ~/.ssh;指定后优先使用)", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -1103,7 +1109,7 @@ static Convert() PrivkeyKey = new Option("--privkey-key") { - Description = "解封目标 --key-file 的私钥(目标 CVK 为 Asymmetric 模式时必需)", + Description = "解封目标 --key-file 的私钥(Asymmetric 模式会自动尝试 ~/.ssh;指定后优先使用)", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -1202,7 +1208,7 @@ static GenKey() PublicKey = new Option>("--public-key") { - Description = "RSA/ECC 公钥 PEM 文件(Asymmetric 模式必需)", + Description = "RSA 公钥文件(PEM 或 OpenSSH ssh-rsa;Asymmetric 模式必需)", HelpName = "pem-file" }.AcceptExistingOnly(); @@ -1241,4 +1247,4 @@ crypvol genkey -o /keys -n project -m Asymmetric --public-key alice.pem return cmd; } } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Convert/ConvertHelper.cs b/CrypVol.Cli/Convert/ConvertHelper.cs index b26d7a0..6ece3b6 100644 --- a/CrypVol.Cli/Convert/ConvertHelper.cs +++ b/CrypVol.Cli/Convert/ConvertHelper.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Volume; +using Microsoft.Extensions.Logging; namespace CrypVol.Cli.Convert; @@ -15,13 +15,15 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var outputDir = args.GetValue(CommandDefinition.Convert.Output)!; var prefix = args.GetValue(CommandDefinition.Convert.OutputPrefix) ?? "converted"; var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(ConvertHelper)); + var discoveryLogger = loggerFactory.CreateLogger("VolumeDiscovery"); // 1. 解析输入卷。此处不修改任何文件;凭据校验完成后才会创建输出或备份。 - var volFiles = VolumeDiscovery.Discover(rawInput, loggerFactory.CreateLogger("VolumeDiscovery")).ToList() + var volFiles = VolumeDiscovery.Discover(rawInput, discoveryLogger).ToList() .AsReadOnly(); if (volFiles.Count is 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 1; } @@ -30,9 +32,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var oldKeyFile = args.GetValue(CommandDefinition.Convert.OldKeyFile); if (oldKeyFile is null) { - oldKeyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, loggerFactory.CreateLogger("VolumeDiscovery")); + oldKeyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, discoveryLogger); if (oldKeyFile is not null) - Console.WriteLine($"自动发现密钥文件: {oldKeyFile.FullName}"); + discoveryLogger.LogInformation("自动发现密钥文件: {KeyFileFullName}", oldKeyFile.FullName); } if (oldKeyFile is not null) @@ -41,12 +43,12 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(oldKeyFile, args.GetValue(CommandDefinition.Convert.OldPassword), args.GetValue(CommandDefinition.Convert.OldPrivkey), - args.GetValue(CommandDefinition.Convert.OldPrivkeyPass), token); + args.GetValue(CommandDefinition.Convert.OldPrivkeyPass), token, logger); oldCreds = cvk.ToCredentials(); } catch (Exception ex) { - Console.Error.WriteLine(ex.Message); + await Console.Error.WriteLineAsync($"无法加载原始密钥文件: {ex.Message}"); return 1; } else @@ -56,7 +58,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var newKeyFile = args.GetValue(CommandDefinition.Convert.KeyFile); if (newKeyFile is null) { - Console.Error.WriteLine("转换必须指定目标密钥文件:--key-file "); + await Console.Error.WriteLineAsync("转换必须指定目标密钥文件:--key-file "); return 1; } @@ -66,12 +68,12 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(newKeyFile, args.GetValue(CommandDefinition.Convert.Password), args.GetValue(CommandDefinition.Convert.PrivkeyKey), - args.GetValue(CommandDefinition.Convert.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Convert.PrivkeyKeyPass), token, logger); newCreds = cvk.ToCredentials(); } catch (Exception ex) { - Console.Error.WriteLine($"无法加载目标密钥文件:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法加载目标密钥文件: {ex.Message}"); return 1; } @@ -86,7 +88,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法准备转换输出或备份:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法准备转换输出或备份: {ex.Message}"); return 1; } @@ -109,22 +111,22 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (OperationCanceledException) when (token.IsCancellationRequested) { - Console.Error.WriteLine("转换已取消"); + await Console.Error.WriteLineAsync("转换已取消"); return 1; } catch (Exception ex) { - Console.Error.WriteLine($"转换失败:{ex.Message}"); + await Console.Error.WriteLineAsync($"转换失败: {ex.Message}"); return 1; } if (!result.Success) { - Console.Error.WriteLine($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"转换失败: {result.Error}"); return 1; } Console.WriteLine($"密钥轮换完成:{result.VolumeCount} 个卷 → {outputDir.FullName}"); return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Extract/ExtractHelper.cs b/CrypVol.Cli/Extract/ExtractHelper.cs index a991cff..2e680e9 100644 --- a/CrypVol.Cli/Extract/ExtractHelper.cs +++ b/CrypVol.Cli/Extract/ExtractHelper.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Volume; +using Microsoft.Extensions.Logging; namespace CrypVol.Cli.Extract; @@ -15,14 +15,16 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var outputDir = args.GetValue(CommandDefinition.Extract.Output)!; var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(ExtractHelper)); + var discoveryLogger = loggerFactory.CreateLogger("VolumeDiscovery"); // 1. 快速校验 + 解析卷文件 if (!outputDir.Exists) outputDir.Create(); - var volFiles = VolumeDiscovery.Discover(rawInput, loggerFactory.CreateLogger("VolumeDiscovery")).ToList() + var volFiles = VolumeDiscovery.Discover(rawInput, discoveryLogger).ToList() .AsReadOnly(); if (volFiles.Count is 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 1; } @@ -31,9 +33,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var keyFile = args.GetValue(CommandDefinition.Extract.KeyFile); if (keyFile is null) { - keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, loggerFactory.CreateLogger("VolumeDiscovery")); + keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, discoveryLogger); if (keyFile is not null) - Console.WriteLine($"自动发现密钥文件: {keyFile.FullName}"); + discoveryLogger.LogInformation("自动发现密钥文件: {KeyFileFullName}", keyFile.FullName); } if (keyFile is not null) @@ -42,12 +44,12 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Extract.Password), args.GetValue(CommandDefinition.Extract.PrivkeyKey), - args.GetValue(CommandDefinition.Extract.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Extract.PrivkeyKeyPass), token, logger); creds = cvk.ToCredentials(); } catch (Exception ex) { - Console.Error.WriteLine(ex.Message); + await Console.Error.WriteLineAsync($"无法加载密钥文件: {ex.Message}"); return 1; } else @@ -70,11 +72,11 @@ public static async Task Invoker(ParseResult args, CancellationToken token) if (!result.Success) { - Console.Error.WriteLine($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"提取失败: {result.Error}"); return 1; } Console.WriteLine($"提取完成:{result.FileCount} 个文件 → {outputDir.FullName}"); return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/GenKey/GenKeyHelper.cs b/CrypVol.Cli/GenKey/GenKeyHelper.cs index 19db60d..20772be 100644 --- a/CrypVol.Cli/GenKey/GenKeyHelper.cs +++ b/CrypVol.Cli/GenKey/GenKeyHelper.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; namespace CrypVol.Cli.GenKey; @@ -13,29 +12,31 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var mode = args.GetValue(CommandDefinition.GenKey.Mode); var password = args.GetValue(CommandDefinition.GenKey.Password); var pubKeys = args.GetValue(CommandDefinition.GenKey.PublicKey)?.ToList() ?? []; - // 1. 快速校验 if (mode == EncryptionMode.None) { - Console.Error.WriteLine("genkey 不支持 None 模式"); + await Console.Error.WriteLineAsync("genkey 不支持 None 模式"); return 1; } + if (string.IsNullOrWhiteSpace(name) || name.EndsWith(".cvk", StringComparison.OrdinalIgnoreCase) - || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.Contains(Path.DirectorySeparatorChar) - || name.Contains(Path.AltDirectorySeparatorChar)) + || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + name.Contains(Path.DirectorySeparatorChar) + || name.Contains(Path.AltDirectorySeparatorChar)) { - Console.Error.WriteLine("密钥名称应为不含扩展名的合法文件名"); + await Console.Error.WriteLineAsync("密钥名称应为不含扩展名的合法文件名"); return 1; } + if (mode == EncryptionMode.Password && string.IsNullOrWhiteSpace(password)) { - Console.Error.WriteLine("Password 模式需要 --password"); + await Console.Error.WriteLineAsync("Password 模式需要 --password"); return 1; } if (mode == EncryptionMode.Asymmetric && !pubKeys.Any()) { - Console.Error.WriteLine("Asymmetric 模式需要 --public-key"); + await Console.Error.WriteLineAsync("Asymmetric 模式需要 --public-key"); return 1; } @@ -48,13 +49,13 @@ public static async Task Invoker(ParseResult args, CancellationToken token) cvk.Comment = args.GetValue(CommandDefinition.GenKey.Comment); foreach (var publicKey in pubKeys) cvk.AddPublicKey(publicKey); await cvk.WriteAsync(new FileInfo(Path.Combine(outputDir.FullName, $"{name}.cvk")), token); - Console.WriteLine($"密钥已生成: {Path.Combine(outputDir.FullName, name + ".cvk")} ({mode})"); + Console.WriteLine($"密钥已生成: {Path.Combine(outputDir.FullName, name + ".cvk")} ({mode})"); return 0; } catch (Exception ex) { - Console.Error.WriteLine($"错误: {ex.Message}"); + await Console.Error.WriteLineAsync($"生成密钥失败: {ex.Message}"); return 1; } } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Info/InfoHelper.cs b/CrypVol.Cli/Info/InfoHelper.cs index 8c9ef76..b003e22 100644 --- a/CrypVol.Cli/Info/InfoHelper.cs +++ b/CrypVol.Cli/Info/InfoHelper.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; namespace CrypVol.Cli.Info; @@ -9,27 +8,60 @@ public static class InfoHelper public static async Task Invoker(ParseResult args, CancellationToken token) { var keyFile = args.GetRequiredValue(CommandDefinition.Info.KeyFile); - var loggerFactory = Program.LoggerFactory; + var logger = Program.LoggerFactory.CreateLogger(nameof(InfoHelper)); try { - var c = await CvkLoader.LoadAsync(keyFile, + var envelopeMode = CvkLoader.ReadMode(keyFile.FullName); + Console.WriteLine($"文件: {keyFile.Name}"); + Console.WriteLine($"封装模式: {EnvelopeModeLabel(envelopeMode)} ({envelopeMode})"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"无法读取 CVK 头: {ex.Message}"); + return 1; + } + + try + { + var document = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Info.Password), args.GetValue(CommandDefinition.Info.PrivkeyKey), - args.GetValue(CommandDefinition.Info.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Info.PrivkeyKeyPass), token, logger); - Console.WriteLine( - $"{keyFile.Name}: {ModeLabel(c.EncryptionMode)} (验证通过, CEK: {System.Convert.ToHexString(c.Cek)[..8]}...)"); + Console.WriteLine("状态: 已解封并验证"); + Console.WriteLine($"内容模式: {ModeLabel(document.EncryptionMode)} ({document.EncryptionMode})"); + Console.WriteLine($"CEK 指纹: {System.Convert.ToHexString(document.Cek)[..8]}..."); + if (!string.IsNullOrWhiteSpace(document.Comment)) + Console.WriteLine($"注释: {document.Comment}"); + + if (document.EncryptionMode == EncryptionMode.Asymmetric) + { + Console.WriteLine($"公钥接收者: {document.PublicKeyRecipients.Count}"); + foreach (var recipient in document.PublicKeyRecipients) + Console.WriteLine($" - {recipient.KeyId}"); + } } catch (Exception ex) { - Console.Error.WriteLine($"读取失败: {ex.Message}"); + await Console.Error.WriteLineAsync($"无法解封 CVK: {ex.Message}"); return 1; } return 0; } + private static string EnvelopeModeLabel(EnvelopeMode mode) + { + return mode switch + { + EnvelopeMode.Plain => "明文 CEK", + EnvelopeMode.Password => "密码保护", + EnvelopeMode.PublicKey => "公钥保护", + _ => "未知" + }; + } + private static string ModeLabel(EncryptionMode m) { return m switch @@ -40,4 +72,4 @@ private static string ModeLabel(EncryptionMode m) _ => "未知" }; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Pack/PackHelper.cs b/CrypVol.Cli/Pack/PackHelper.cs index 389c84d..2e3f3ba 100644 --- a/CrypVol.Cli/Pack/PackHelper.cs +++ b/CrypVol.Cli/Pack/PackHelper.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; @@ -12,31 +11,43 @@ public static class PackHelper public static async Task Invoker(ParseResult args, CancellationToken token) { var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(PackHelper)); var inputPath = args.GetRequiredValue(CommandDefinition.Pack.InputPath); if (!inputPath.Exists) { - Console.Error.WriteLine("源路径不存在"); + await Console.Error.WriteLineAsync("源路径不存在"); return 1; } var outputDir = args.GetRequiredValue(CommandDefinition.Pack.OutputPath); + var mode = args.GetValue(CommandDefinition.Pack.Mode); + var keyFile = args.GetValue(CommandDefinition.Pack.KeyFile); + var publicKeys = args.GetValue(CommandDefinition.Pack.PublicKey)?.ToList() ?? []; + + if (keyFile is null && mode == EncryptionMode.Asymmetric && publicKeys.Count == 0) + { + await Console.Error.WriteLineAsync("参数错误:--mode Asymmetric 必须至少提供一个 --public-key;也可以改用 PlainKey 或 Password 模式。"); + return 1; + } var prefix = args.GetValue(CommandDefinition.Pack.OutputPrefix); if (string.IsNullOrWhiteSpace(prefix)) { - Console.Error.WriteLine("无效前缀"); + await Console.Error.WriteLineAsync("无效前缀"); return 1; } + if (prefix.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || prefix.Contains(Path.DirectorySeparatorChar) - || prefix.Contains(Path.AltDirectorySeparatorChar)) + || prefix.Contains(Path.AltDirectorySeparatorChar)) { - Console.Error.WriteLine("输出前缀不能包含路径分隔符或非法文件名字符"); + await Console.Error.WriteLineAsync("输出前缀不能包含路径分隔符或非法文件名字符"); return 1; } + if (args.GetValue(CommandDefinition.Pack.VolumeSize) == 0) { - Console.Error.WriteLine("卷切分目标必须大于 0 MiB"); + await Console.Error.WriteLineAsync("卷切分目标必须大于 0 MiB"); return 1; } @@ -66,14 +77,14 @@ public static async Task Invoker(ParseResult args, CancellationToken token) if (files.Count == 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 1; } var chunkSizeMb = args.GetValue(CommandDefinition.Pack.ChunkSize); if (chunkSizeMb is < 1 or > 64) { - Console.Error.WriteLine("块大小必须在 1–64 MiB 之间"); + await Console.Error.WriteLineAsync("块大小必须在 1–64 MiB 之间"); return 1; } @@ -91,8 +102,6 @@ public static async Task Invoker(ParseResult args, CancellationToken token) // Pre-load/generate CEK CvkCredentials? creds; - var mode = args.GetValue(CommandDefinition.Pack.Mode); - var keyFile = args.GetValue(CommandDefinition.Pack.KeyFile); if (keyFile is not null) { try @@ -100,27 +109,21 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Pack.Password), args.GetValue(CommandDefinition.Pack.PrivkeyKey), - args.GetValue(CommandDefinition.Pack.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Pack.PrivkeyKeyPass), token, logger); creds = cvk.ToCredentials(); } catch (Exception ex) { - Console.Error.WriteLine(ex.Message); + await Console.Error.WriteLineAsync($"无法加载密钥文件: {ex.Message}"); return 1; } } else { var password = args.GetValue(CommandDefinition.Pack.Password); - var publicKeys = args.GetValue(CommandDefinition.Pack.PublicKey)?.ToList() ?? []; if (mode == EncryptionMode.Password && string.IsNullOrWhiteSpace(password)) { - Console.Error.WriteLine("Password 模式需要 --password"); - return 1; - } - if (mode == EncryptionMode.Asymmetric && publicKeys.Count == 0) - { - Console.Error.WriteLine("Asymmetric 模式需要 --public-key"); + await Console.Error.WriteLineAsync("Password 模式需要 --password"); return 1; } @@ -138,7 +141,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"创建密钥文件失败: {ex.Message}"); + await Console.Error.WriteLineAsync($"创建密钥文件失败: {ex.Message}"); return 1; } } @@ -149,7 +152,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法创建输出目录:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法创建输出目录: {ex.Message}"); return 1; } @@ -165,20 +168,19 @@ public static async Task Invoker(ParseResult args, CancellationToken token) OutputPrefix = prefix, VolumeSizeMb = args.GetValue(CommandDefinition.Pack.VolumeSize), ChunkSizeMb = chunkSizeMb, - EnableCompression = args.GetValue(CommandDefinition.Pack.Compress), + EnableCompression = !args.GetValue(CommandDefinition.Pack.NoCompress), CompressionLevel = args.GetValue(CommandDefinition.Pack.CompressionLevel), - KeyOutputDir = args.GetValue(CommandDefinition.Pack.KeyOutputPath)!, IntegrityLevel = args.GetValue(CommandDefinition.Pack.Integrity), Credentials = creds }, token); if (!result.Success) { - Console.Error.WriteLine($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"打包失败: {result.Error}"); return 1; } Console.WriteLine($"打包完成:{result.VolumeCount} 个卷 → {outputDir.FullName}"); return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Rekey/RekeyHelper.cs b/CrypVol.Cli/Rekey/RekeyHelper.cs index 770376c..05b7cc5 100644 --- a/CrypVol.Cli/Rekey/RekeyHelper.cs +++ b/CrypVol.Cli/Rekey/RekeyHelper.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; namespace CrypVol.Cli.Rekey; @@ -12,6 +11,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var toMode = args.GetValue(CommandDefinition.Rekey.ToMode); var newPassword = args.GetValue(CommandDefinition.Rekey.NewPassword); var publicKeys = args.GetValue(CommandDefinition.Rekey.PublicKey)?.ToList() ?? []; + var logger = Program.LoggerFactory.CreateLogger(nameof(RekeyHelper)); // 1. 加载完整文档,保留注释和已有的公钥接收者槽位。 CvkDocument document; @@ -20,36 +20,36 @@ public static async Task Invoker(ParseResult args, CancellationToken token) document = await CvkLoader.LoadAsync(cvkFile, args.GetValue(CommandDefinition.Rekey.Password), args.GetValue(CommandDefinition.Rekey.PrivkeyKey), - args.GetValue(CommandDefinition.Rekey.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Rekey.PrivkeyKeyPass), token, logger); } catch (Exception ex) { - Console.Error.WriteLine($"无法加载 CVK:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法加载 CVK: {ex.Message}"); return 1; } // 2. 在任何写入前完成参数校验并构建内容,避免无效参数造成备份或覆写。 if (toMode is EncryptionMode.None || !Enum.IsDefined(toMode)) { - Console.Error.WriteLine("rekey 的目标模式必须是 PlainKey、Password 或 Asymmetric。"); + await Console.Error.WriteLineAsync("rekey 的目标模式必须是 PlainKey、Password 或 Asymmetric。"); return 1; } if (toMode == EncryptionMode.Password && string.IsNullOrWhiteSpace(newPassword)) { - Console.Error.WriteLine("Password 模式需要 --new-password。"); + await Console.Error.WriteLineAsync("Password 模式需要 --new-password。"); return 1; } if (toMode != EncryptionMode.Password && !string.IsNullOrWhiteSpace(newPassword)) { - Console.Error.WriteLine("--new-password 仅可与 --to-mode Password 一起使用。"); + await Console.Error.WriteLineAsync("--new-password 仅可与 --to-mode Password 一起使用。"); return 1; } if (toMode != EncryptionMode.Asymmetric && publicKeys.Count > 0) { - Console.Error.WriteLine("--public-key 仅可与 --to-mode Asymmetric 一起使用。"); + await Console.Error.WriteLineAsync("--public-key 仅可与 --to-mode Asymmetric 一起使用。"); return 1; } @@ -59,7 +59,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); if (!sameAsSource && outputFile.Exists) { - Console.Error.WriteLine($"输出文件已存在:{outputFile.FullName}"); + await Console.Error.WriteLineAsync($"输出文件已存在:{outputFile.FullName}"); return 1; } @@ -79,9 +79,10 @@ public static async Task Invoker(ParseResult args, CancellationToken token) foreach (var publicKey in publicKeys) document.AddPublicKey(publicKey); if (document.PublicKeyRecipients.Count == 0 && document.NewPublicKeyFiles.Count == 0) { - Console.Error.WriteLine("Asymmetric 模式至少需要一个现有或通过 --public-key 指定的接收者。"); + await Console.Error.WriteLineAsync("Asymmetric 模式至少需要一个现有或通过 --public-key 指定的接收者。"); return 1; } + break; } @@ -92,7 +93,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法构建 CVK:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法构建 CVK: {ex.Message}"); return 1; } @@ -102,7 +103,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) { if (backupFile.Exists) { - Console.Error.WriteLine($"备份文件已存在,拒绝覆盖:{backupFile.FullName}"); + await Console.Error.WriteLineAsync($"备份文件已存在,拒绝覆盖:{backupFile.FullName}"); return 1; } @@ -112,7 +113,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法创建备份:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法创建备份: {ex.Message}"); return 1; } } @@ -120,7 +121,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var outputDirectory = outputFile.Directory; if (outputDirectory is null) { - Console.Error.WriteLine("输出文件路径无效。"); + await Console.Error.WriteLineAsync("输出文件路径无效。"); return 1; } @@ -133,7 +134,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) } catch (Exception ex) { - Console.Error.WriteLine($"无法写入 CVK:{ex.Message}"); + await Console.Error.WriteLineAsync($"无法写入 CVK: {ex.Message}"); return 1; } finally @@ -144,4 +145,4 @@ public static async Task Invoker(ParseResult args, CancellationToken token) Console.WriteLine($"密钥已重新封装 → {outputFile.FullName}"); return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Repair/RepairHelper.cs b/CrypVol.Cli/Repair/RepairHelper.cs index 0ad471b..07dbc30 100644 --- a/CrypVol.Cli/Repair/RepairHelper.cs +++ b/CrypVol.Cli/Repair/RepairHelper.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Volume; +using Microsoft.Extensions.Logging; namespace CrypVol.Cli.Repair; @@ -14,13 +14,15 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var rawInput = args.GetRequiredValue(CommandDefinition.Repair.VolFiles); var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(RepairHelper)); + var discoveryLogger = loggerFactory.CreateLogger("VolumeDiscovery"); // 1. 发现所有卷文件 - var volFiles = VolumeDiscovery.Discover(rawInput, loggerFactory.CreateLogger("VolumeDiscovery")).ToList() + var volFiles = VolumeDiscovery.Discover(rawInput, discoveryLogger).ToList() .AsReadOnly(); if (volFiles.Count is 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 1; } @@ -29,9 +31,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var keyFile = args.GetValue(CommandDefinition.Repair.KeyFile); if (keyFile is null) { - keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, loggerFactory.CreateLogger("VolumeDiscovery")); + keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, discoveryLogger); if (keyFile is not null) - Console.WriteLine($"自动发现密钥文件: {keyFile.FullName}"); + discoveryLogger.LogInformation("自动发现密钥文件: {KeyFileFullName}", keyFile.FullName); } if (keyFile is not null) @@ -40,7 +42,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Repair.Password), args.GetValue(CommandDefinition.Repair.PrivkeyKey), - args.GetValue(CommandDefinition.Repair.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Repair.PrivkeyKeyPass), token, logger); creds = cvk.ToCredentials(); } catch (Exception ex) @@ -67,7 +69,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) if (!result.Success) { - await Console.Error.WriteLineAsync($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"修复失败: {result.Error}"); return 1; } @@ -79,9 +81,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) { Console.WriteLine($"修复损坏块: {result.RepairedBlocks}"); foreach (var v in result.RepairedVolumes) - Console.WriteLine($" {v}"); + Console.WriteLine($"修复卷: {v}"); } return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Cli/Verify/VerifyHelper.cs b/CrypVol.Cli/Verify/VerifyHelper.cs index a8d7813..190ac13 100644 --- a/CrypVol.Cli/Verify/VerifyHelper.cs +++ b/CrypVol.Cli/Verify/VerifyHelper.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Volume; +using Microsoft.Extensions.Logging; namespace CrypVol.Cli.Verify; @@ -14,13 +14,15 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var rawInput = args.GetRequiredValue(CommandDefinition.Verify.VolFiles); var loggerFactory = Program.LoggerFactory; + var logger = loggerFactory.CreateLogger(nameof(VerifyHelper)); + var discoveryLogger = loggerFactory.CreateLogger("VolumeDiscovery"); // 1. 发现所有卷文件 - var volFiles = VolumeDiscovery.Discover(rawInput, loggerFactory.CreateLogger("VolumeDiscovery")).ToList() + var volFiles = VolumeDiscovery.Discover(rawInput, discoveryLogger).ToList() .AsReadOnly(); if (volFiles.Count is 0) { - Console.Error.WriteLine("无可处理文件"); + await Console.Error.WriteLineAsync("无可处理文件"); return 2; } @@ -29,9 +31,9 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var keyFile = args.GetValue(CommandDefinition.Verify.KeyFile); if (keyFile is null) { - keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, loggerFactory.CreateLogger("VolumeDiscovery")); + keyFile = VolumeDiscovery.DiscoverKeyFile(volFiles, discoveryLogger); if (keyFile is not null) - Console.WriteLine($"自动发现密钥文件: {keyFile.FullName}"); + discoveryLogger.LogInformation("自动发现密钥文件: {KeyFileFullName}", keyFile.FullName); } if (keyFile is not null) @@ -40,7 +42,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) var cvk = await CvkLoader.LoadAsync(keyFile, args.GetValue(CommandDefinition.Verify.Password), args.GetValue(CommandDefinition.Verify.PrivkeyKey), - args.GetValue(CommandDefinition.Verify.PrivkeyKeyPass), token); + args.GetValue(CommandDefinition.Verify.PrivkeyKeyPass), token, logger); creds = cvk.ToCredentials(); } catch (Exception ex) @@ -67,7 +69,7 @@ public static async Task Invoker(ParseResult args, CancellationToken token) if (!result.Success) { - await Console.Error.WriteLineAsync($"错误: {result.Error}"); + await Console.Error.WriteLineAsync($"校验失败: {result.Error}"); return 2; } @@ -84,19 +86,27 @@ public static async Task Invoker(ParseResult args, CancellationToken token) Console.WriteLine($"损坏文件: {result.CorruptedFiles}"); Console.WriteLine($"损坏数据块: {result.CorruptedBlocks}"); foreach (var e in result.CorruptedEntries) - Console.WriteLine($" {e.FilePath} offset={e.CvpOffset} size={e.BlockSize}"); + Console.WriteLine($" {e.FilePath} volume={e.VolumePath} offset={e.CvpOffset} size={e.BlockSize}"); var repairReport = args.GetValue(CommandDefinition.Verify.RepairReport); if (repairReport is not null) - { - await File.WriteAllLinesAsync(repairReport.FullName, - result.CorruptedEntries.Select(e => $"{e.FilePath}\t{e.CvpOffset}\t{e.BlockSize}"), token); - Console.WriteLine($"损坏报告: {repairReport.FullName}"); - } + try + { + repairReport.Directory?.Create(); + await File.WriteAllLinesAsync(repairReport.FullName, + result.CorruptedEntries.Select(e => + $"{e.VolumePath}\t{e.CvpOffset}\t{e.BlockSize}\t{e.FilePath}"), token); + Console.WriteLine($"损坏报告: {repairReport.FullName}"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"无法写入损坏报告 {repairReport.FullName}: {ex.Message}"); + return 2; + } return 1; } return 0; } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/CrypVol.Lib.csproj b/CrypVol.Lib/CrypVol.Lib.csproj index c7cf427..22c4bff 100644 --- a/CrypVol.Lib/CrypVol.Lib.csproj +++ b/CrypVol.Lib/CrypVol.Lib.csproj @@ -18,8 +18,10 @@ + + diff --git a/CrypVol.Lib/Crypto/CvkCredentials.cs b/CrypVol.Lib/Crypto/CvkCredentials.cs index 4ccada3..958e2c6 100644 --- a/CrypVol.Lib/Crypto/CvkCredentials.cs +++ b/CrypVol.Lib/Crypto/CvkCredentials.cs @@ -3,4 +3,4 @@ namespace CrypVol.Lib.Crypto; /// 进入处理引擎的轻量加密凭据,仅携带运行所需的模式与 CEK。 /// 内容加密模式。 /// 内容加密密钥。 -public sealed record CvkCredentials(EncryptionMode EncryptionMode, byte[] Cek); +public sealed record CvkCredentials(EncryptionMode EncryptionMode, byte[] Cek); \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/CvkDocument.cs b/CrypVol.Lib/Crypto/CvkDocument.cs index e8e37dc..059e55b 100644 --- a/CrypVol.Lib/Crypto/CvkDocument.cs +++ b/CrypVol.Lib/Crypto/CvkDocument.cs @@ -10,11 +10,6 @@ public sealed class CvkDocument { private readonly List _newPublicKeyFiles = []; private byte[]? _publicKeyDek; - /// 创建带随机 CEK 的 CVK 内容模型。 - /// CEK 封装模式。 - /// 可编辑的 CVK 内容模型。 - public static CvkDocument CreateNew(EncryptionMode encryptionMode) => - new(RandomNumberGenerator.GetBytes(32), encryptionMode); /// 创建 CVK 内容模型。 /// 要由 CVK 封装的 32 字节内容加密密钥。 @@ -34,14 +29,25 @@ public CvkDocument(byte[] cek, EncryptionMode encryptionMode) /// 密码封装时用于派生密钥的密码。 public string? Password { get; set; } - /// 已存在的公钥接收者槽位;可在缺少原始 PEM 文件时保留或删除。 + /// 已存在的公钥接收者槽位;可在缺少原始公钥文件时保留或删除。 public IList PublicKeyRecipients { get; } = new List(); - /// 待新增的 PEM 公钥文件;构建时会生成新的接收者槽位。 + /// 待新增的 RSA 公钥文件;构建时会生成新的接收者槽位。 public IReadOnlyList NewPublicKeyFiles => _newPublicKeyFiles; + /// 可选注释。 + public string? Comment { get; set; } + + /// 创建带随机 CEK 的 CVK 内容模型。 + /// CEK 封装模式。 + /// 可编辑的 CVK 内容模型。 + public static CvkDocument CreateNew(EncryptionMode encryptionMode) + { + return new CvkDocument(RandomNumberGenerator.GetBytes(32), encryptionMode); + } + /// 添加一个公钥接收者。 - /// PEM 公钥文件。 + /// PEM 或 OpenSSH ssh-rsa 公钥文件。 public void AddPublicKey(FileInfo publicKeyFile) { ArgumentNullException.ThrowIfNull(publicKeyFile); @@ -52,7 +58,7 @@ public void AddPublicKey(FileInfo publicKeyFile) } /// 移除一个公钥接收者。 - /// 要移除的 PEM 公钥文件。 + /// 要移除的公钥文件。 /// 是否移除了接收者。 public bool RemovePublicKey(FileInfo publicKeyFile) { @@ -74,7 +80,7 @@ public bool RemovePublicKey(string keyId) var removedSlot = recipient is not null && PublicKeyRecipients.Remove(recipient); var pending = _newPublicKeyFiles.FirstOrDefault(file => string.Equals( Path.GetFileNameWithoutExtension(file.Name), keyId, StringComparison.Ordinal)); - return (pending is not null && _newPublicKeyFiles.Remove(pending)) || removedSlot; + return pending is not null && _newPublicKeyFiles.Remove(pending) || removedSlot; } /// 清空全部既有及待新增的公钥接收者。 @@ -84,12 +90,12 @@ public void ClearPublicKeys() _newPublicKeyFiles.Clear(); } - /// 可选注释。 - public string? Comment { get; set; } - /// 提取进入处理引擎所需的轻量凭据。 /// 仅包含内容加密模式与 CEK 的运行凭据。 - public CvkCredentials ToCredentials() => new(EncryptionMode, Cek); + public CvkCredentials ToCredentials() + { + return new CvkCredentials(EncryptionMode, Cek); + } /// 按现有 KEY0 v1 格式构建二进制 CVK 内容。 /// 可直接 Base64 编码或写入的二进制 CVK 内容。 @@ -143,7 +149,10 @@ public byte[] Build() /// 构建可直接保存为 .cvk 文件的 Base64 文本。 /// Base64 编码后的 CVK 内容;无加密模式返回空字符串。 - public string BuildBase64() => EncryptionMode == EncryptionMode.None ? string.Empty : Convert.ToBase64String(Build()); + public string BuildBase64() + { + return EncryptionMode == EncryptionMode.None ? string.Empty : Convert.ToBase64String(Build()); + } /// 将构建后的内容写入指定 CVK 文件。 /// 目标 CVK 文件。 @@ -198,15 +207,14 @@ private void WritePublicKeyPayload(BinaryWriter writer) var dek = _publicKeyDek ??= RandomNumberGenerator.GetBytes(32); foreach (var file in _newPublicKeyFiles) { - using var rsa = RSA.Create(); - rsa.ImportFromPem(File.ReadAllText(file.FullName)); - var keyId = Path.GetFileNameWithoutExtension(file.Name); + using var rsa = RsaKeyLoader.LoadPublicKey(File.ReadAllText(file.FullName)); + var keyId = CvkKeyIdResolver.Resolve(file); recipients[keyId] = new CvkPublicKeyRecipient(keyId, rsa.Encrypt(dek, RSAEncryptionPadding.OaepSHA256)); } if (recipients.Count == 0) - throw new Exception("至少需要一个接收者公钥。"); + throw new CvkValidationException("Asymmetric 模式必须提供至少一个公钥接收者。请指定 --public-key,或改用其他 --mode。"); if (recipients.Count > ushort.MaxValue) throw new InvalidOperationException("接收者数量不能超过 65535。"); @@ -235,4 +243,4 @@ private void WritePublicKeyPayload(BinaryWriter writer) foreach (var recipient in resolvedRecipients) PublicKeyRecipients.Add(recipient); _newPublicKeyFiles.Clear(); } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/CvkKeyIdResolver.cs b/CrypVol.Lib/Crypto/CvkKeyIdResolver.cs new file mode 100644 index 0000000..df88921 --- /dev/null +++ b/CrypVol.Lib/Crypto/CvkKeyIdResolver.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; + +namespace CrypVol.Lib.Crypto; + +/// 按公钥格式生成稳定的 CVK 接收者标识。 +public static partial class CvkKeyIdResolver +{ + /// 从公钥文件解析接收者标识。 + /// RSA PEM 或 OpenSSH 公钥文件。 + /// OpenSSH 公钥注释中的邮箱地址,或去掉扩展名的文件名。 + public static string Resolve(FileInfo publicKeyFile) + { + ArgumentNullException.ThrowIfNull(publicKeyFile); + var fallback = Path.GetFileNameWithoutExtension(publicKeyFile.Name); + var keyText = File.ReadAllText(publicKeyFile.FullName); + if (!keyText.TrimStart().StartsWith("ssh-rsa ", StringComparison.Ordinal)) return fallback; + + var fields = keyText.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return fields.Skip(2).FirstOrDefault(IsEmailAddress) ?? fallback; + } + + private static bool IsEmailAddress(string value) + { + return EmailAddressPattern().IsMatch(value); + } + + [GeneratedRegex("^[^\\s@]+@[^\\s@]+$")] + private static partial Regex EmailAddressPattern(); +} \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/CvkLoader.cs b/CrypVol.Lib/Crypto/CvkLoader.cs index 5c3dfdd..81740da 100644 --- a/CrypVol.Lib/Crypto/CvkLoader.cs +++ b/CrypVol.Lib/Crypto/CvkLoader.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using Konscious.Security.Cryptography; +using Microsoft.Extensions.Logging; namespace CrypVol.Lib.Crypto; @@ -24,10 +25,11 @@ public static EnvelopeMode ReadMode(string path) /// 公钥封装时的匹配私钥。 /// 加密私钥的密码。 /// 取消令牌。 + /// 自动发现私钥时使用的可选日志记录器。 /// 包含解包 CEK 与原有模式、注释的可编辑对象。 public static async Task LoadAsync(FileInfo file, string? password = null, FileInfo? privateKeyFile = null, string? privateKeyPassword = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(file); if (!file.Exists) throw new FileNotFoundException("密钥文件不存在", file.FullName); @@ -45,7 +47,7 @@ public static async Task LoadAsync(FileInfo file, string? password break; case EnvelopeMode.PublicKey: var payload = await ReadPublicKeyPayloadAsync(reader, privateKeyFile, privateKeyPassword, - cancellationToken); + cancellationToken, logger); document = new CvkDocument(payload.Cek, EncryptionMode.Asymmetric); document.LoadPublicKeyRecipients(payload.Recipients, payload.Dek); break; @@ -83,17 +85,9 @@ private static byte[] ReadPasswordPayload(BinaryReader reader, string? password) } private static async Task ReadPublicKeyPayloadAsync(BinaryReader reader, FileInfo? privateKeyFile, - string? privateKeyPassword, CancellationToken cancellationToken) + string? privateKeyPassword, CancellationToken cancellationToken, ILogger? logger) { - if (privateKeyFile is null) - throw new InvalidOperationException("密钥受公钥保护,请提供私钥。"); - using var rsa = RSA.Create(); - var pem = await File.ReadAllTextAsync(privateKeyFile.FullName, cancellationToken); - if (privateKeyPassword is null) rsa.ImportFromPem(pem); - else rsa.ImportFromEncryptedPem(pem, privateKeyPassword); - var count = BinaryPrimitives.ReverseEndianness(reader.ReadUInt16()); - byte[]? dek = null; var recipients = new List(count); for (var index = 0; index < count; index++) { @@ -102,14 +96,13 @@ private static async Task ReadPublicKeyPayloadAsync(BinaryRead var encryptedDekLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt16()); var encryptedDek = reader.ReadBytes(encryptedDekLength); recipients.Add(new CvkPublicKeyRecipient(keyId, encryptedDek)); - if (dek is null) - { - try { dek = rsa.Decrypt(encryptedDek, RSAEncryptionPadding.OaepSHA256); } - catch (CryptographicException) { } - } } - if (dek is null) throw new InvalidOperationException("没有匹配的私钥。"); + var dek = privateKeyFile is not null + ? await TryDecryptDekAsync(privateKeyFile, recipients, privateKeyPassword, false, cancellationToken) + : await FindDekFromUserSshKeysAsync(recipients, privateKeyPassword, cancellationToken, logger); + if (dek is null) + throw new InvalidOperationException("未找到可用私钥。请通过 --privkey-key 明确指定匹配的 RSA 私钥。"); var nonce = reader.ReadBytes(12); var tag = reader.ReadBytes(16); var ciphertext = reader.ReadBytes(32); @@ -119,6 +112,52 @@ private static async Task ReadPublicKeyPayloadAsync(BinaryRead return new PublicKeyPayload(cek, dek, recipients); } + private static async Task FindDekFromUserSshKeysAsync(IReadOnlyList recipients, + string? privateKeyPassword, CancellationToken cancellationToken, ILogger? logger) + { + foreach (var privateKeyFile in SshPrivateKeyDiscovery.DiscoverUserPrivateKeys()) + { + cancellationToken.ThrowIfCancellationRequested(); + var dek = await TryDecryptDekAsync(privateKeyFile, recipients, privateKeyPassword, true, cancellationToken); + if (dek is not null) + { + logger?.LogInformation("已自动发现并使用 SSH 私钥 {PrivateKeyPath}", privateKeyFile.FullName); + return dek; + } + } + + return null; + } + + private static async Task TryDecryptDekAsync(FileInfo privateKeyFile, + IReadOnlyList recipients, string? privateKeyPassword, bool suppressCandidateErrors, + CancellationToken cancellationToken) + { + try + { + var pem = await File.ReadAllTextAsync(privateKeyFile.FullName, cancellationToken); + using var rsa = RsaKeyLoader.LoadPrivateKey(pem, privateKeyPassword); + + foreach (var recipient in recipients) + try { return rsa.Decrypt(recipient.EncryptedDek, RSAEncryptionPadding.OaepSHA256); } + catch (CryptographicException) { } + + return null; + } + catch (Exception exception) when (suppressCandidateErrors && IsUnreadableOrUnsupportedKey(exception)) + { + return null; + } + } + + private static bool IsUnreadableOrUnsupportedKey(Exception exception) + { + return exception is CryptographicException + or IOException + or UnauthorizedAccessException + or ArgumentException; + } + private static string? ReadComment(BinaryReader reader, long payloadEnd) { if (reader.BaseStream.Position == payloadEnd) return null; @@ -132,7 +171,7 @@ private static async Task ReadPublicKeyPayloadAsync(BinaryRead private static BinaryReader OpenPayload(byte[] data, out EnvelopeMode mode, out long payloadEnd) { - var stream = new MemoryStream(data, writable: false); + var stream = new MemoryStream(data, false); var reader = new BinaryReader(stream); if (Encoding.ASCII.GetString(reader.ReadBytes(4)) != "KEY0" || reader.ReadByte() != 1) throw new InvalidDataException("无效的密钥文件。"); @@ -144,6 +183,7 @@ private static BinaryReader OpenPayload(byte[] data, out EnvelopeMode mode, out return reader; } - private sealed record PublicKeyPayload(byte[] Cek, byte[] Dek, + private sealed record PublicKeyPayload(byte[] Cek, + byte[] Dek, IReadOnlyList Recipients); -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/CvkPublicKeyRecipient.cs b/CrypVol.Lib/Crypto/CvkPublicKeyRecipient.cs index 92197c2..a5bf537 100644 --- a/CrypVol.Lib/Crypto/CvkPublicKeyRecipient.cs +++ b/CrypVol.Lib/Crypto/CvkPublicKeyRecipient.cs @@ -1,6 +1,6 @@ namespace CrypVol.Lib.Crypto; -/// CVK 公钥模式中的接收者槽位;可在没有原始 PEM 文件时保留或移除。 +/// CVK 公钥模式中的接收者槽位;可在没有原始公钥文件时保留或移除。 public sealed class CvkPublicKeyRecipient { internal CvkPublicKeyRecipient(string keyId, byte[] encryptedDek) @@ -14,4 +14,4 @@ internal CvkPublicKeyRecipient(string keyId, byte[] encryptedDek) /// 由该接收者公钥包裹的 DEK。 public byte[] EncryptedDek { get; } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/CvkValidationException.cs b/CrypVol.Lib/Crypto/CvkValidationException.cs new file mode 100644 index 0000000..87a69e9 --- /dev/null +++ b/CrypVol.Lib/Crypto/CvkValidationException.cs @@ -0,0 +1,12 @@ +namespace CrypVol.Lib.Crypto; + +/// 表示 CVK 构建参数不符合所选封装模式的错误。 +public sealed class CvkValidationException : InvalidOperationException +{ + /// 使用指定错误信息创建异常。 + /// 可直接展示给用户的参数错误说明。 + public CvkValidationException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/CrypVol.Lib/EncryptionMode.cs b/CrypVol.Lib/Crypto/EncryptionMode.cs similarity index 76% rename from CrypVol.Lib/EncryptionMode.cs rename to CrypVol.Lib/Crypto/EncryptionMode.cs index 8f6227c..9cd15e7 100644 --- a/CrypVol.Lib/EncryptionMode.cs +++ b/CrypVol.Lib/Crypto/EncryptionMode.cs @@ -1,4 +1,4 @@ -namespace CrypVol.Lib; +namespace CrypVol.Lib.Crypto; /// 密钥保护模式枚举 public enum EncryptionMode @@ -12,6 +12,6 @@ public enum EncryptionMode /// 密码包裹:CEK 经 Argon2id + AES-GCM 加密存储在 .cvk,解密需密码 Password, - /// 公钥包裹:CEK 经 RSA/ECC 公钥加密存储在 .cvk,解密需对应私钥 + /// 公钥包裹:CEK 经 RSA 公钥加密存储在 .cvk,解密需对应私钥 Asymmetric } \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/RsaKeyLoader.cs b/CrypVol.Lib/Crypto/RsaKeyLoader.cs new file mode 100644 index 0000000..62ecfdd --- /dev/null +++ b/CrypVol.Lib/Crypto/RsaKeyLoader.cs @@ -0,0 +1,177 @@ +using System.Numerics; +using System.Security.Cryptography; +using System.Text; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Utilities; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities.IO.Pem; +using Renci.SshNet; +using Renci.SshNet.Common; +using SshRsaKey = Renci.SshNet.Security.RsaKey; + +namespace CrypVol.Lib.Crypto; + +/// 加载 CVK 公钥模式所需的 RSA 密钥,兼容 PEM 与 OpenSSH 文本格式。 +public static class RsaKeyLoader +{ + /// 加载 RSA 公钥。 + /// PEM 或 ssh-rsa 单行公钥内容。 + /// 可用于 RSA-OAEP 加密的公钥实例。 + public static RSA LoadPublicKey(string keyText) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyText); + if (TryLoadPemPublicKey(keyText, out var rsa)) return rsa; + + var fields = keyText.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 2 || !string.Equals(fields[0], "ssh-rsa", StringComparison.Ordinal)) + throw new CryptographicException("不支持的公钥格式。请提供 RSA PEM 或 OpenSSH ssh-rsa 公钥。"); + + try + { + var parameters = OpenSshPublicKeyUtilities.ParsePublicKey(Convert.FromBase64String(fields[1])); + if (parameters is not RsaKeyParameters rsaParameters || rsaParameters.IsPrivate) + throw new CryptographicException("公钥不是 RSA 密钥。"); + return CreateRsa(rsaParameters); + } + catch (FormatException exception) + { + throw new CryptographicException("OpenSSH 公钥的 Base64 内容无效。", exception); + } + } + + /// 加载 RSA 私钥。 + /// PEM、OpenSSH、ssh.com 或 PuTTY 私钥内容。 + /// 加密私钥的密码。 + /// 可用于 RSA-OAEP 解密的私钥实例。 + public static RSA LoadPrivateKey(string keyText, string? password = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyText); + if (TryLoadPemPrivateKey(keyText, password, out var rsa)) return rsa; + if (string.IsNullOrEmpty(password) && TryLoadUnencryptedOpenSshPrivateKey(keyText, out rsa)) return rsa; + + try + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(keyText), false); + using var privateKeyFile = new PrivateKeyFile(stream, password); + if (privateKeyFile.Key is not SshRsaKey sshRsaKey) + throw new CryptographicException("私钥不是 RSA 密钥。"); + return CreateRsa(sshRsaKey); + } + catch (SshException exception) + { + throw new CryptographicException("私钥格式无效,或 --key-pass 不正确。", exception); + } + catch (NotSupportedException exception) + { + throw new CryptographicException("不支持的私钥格式或算法。仅支持 RSA 私钥。", exception); + } + catch (ArgumentException exception) + { + throw new CryptographicException("私钥格式无效,或 --key-pass 不正确。", exception); + } + } + + private static bool TryLoadPemPublicKey(string keyText, out RSA rsa) + { + rsa = RSA.Create(); + try + { + rsa.ImportFromPem(keyText); + return true; + } + catch (ArgumentException) + { + rsa.Dispose(); + rsa = null!; + return false; + } + } + + private static bool TryLoadPemPrivateKey(string keyText, string? password, out RSA rsa) + { + rsa = RSA.Create(); + try + { + if (string.IsNullOrEmpty(password)) rsa.ImportFromPem(keyText); + else rsa.ImportFromEncryptedPem(keyText, password); + return true; + } + catch (ArgumentException) + { + rsa.Dispose(); + rsa = null!; + return false; + } + catch (CryptographicException) + { + rsa.Dispose(); + rsa = null!; + return false; + } + } + + private static bool TryLoadUnencryptedOpenSshPrivateKey(string keyText, out RSA rsa) + { + rsa = null!; + if (!keyText.Contains("-----BEGIN OPENSSH PRIVATE KEY-----", StringComparison.Ordinal)) return false; + + try + { + using var reader = new StringReader(keyText); + var pem = new PemReader(reader).ReadPemObject(); + if (pem is null) return false; + var parameters = OpenSshPrivateKeyUtilities.ParsePrivateKeyBlob(pem.Content); + if (parameters is not RsaPrivateCrtKeyParameters rsaParameters) return false; + rsa = CreateRsa(rsaParameters); + return true; + } + catch (IOException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static RSA CreateRsa(RsaKeyParameters parameters) + { + var rsa = RSA.Create(); + rsa.ImportParameters(DotNetUtilities.ToRSAParameters(parameters)); + return rsa; + } + + private static RSA CreateRsa(RsaPrivateCrtKeyParameters parameters) + { + var rsa = RSA.Create(); + rsa.ImportParameters(DotNetUtilities.ToRSAParameters(parameters)); + return rsa; + } + + private static RSA CreateRsa(SshRsaKey key) + { + var rsa = RSA.Create(); + rsa.ImportParameters(new RSAParameters + { + Modulus = ToUnsignedBigEndian(key.Modulus), + Exponent = ToUnsignedBigEndian(key.Exponent), + D = ToUnsignedBigEndian(key.D), + P = ToUnsignedBigEndian(key.P), + Q = ToUnsignedBigEndian(key.Q), + DP = ToUnsignedBigEndian(key.DP), + DQ = ToUnsignedBigEndian(key.DQ), + InverseQ = ToUnsignedBigEndian(key.InverseQ) + }); + return rsa; + } + + private static byte[] ToUnsignedBigEndian(BigInteger value) + { + return value.ToByteArray(true, true); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Crypto/SshPrivateKeyDiscovery.cs b/CrypVol.Lib/Crypto/SshPrivateKeyDiscovery.cs new file mode 100644 index 0000000..cf154b2 --- /dev/null +++ b/CrypVol.Lib/Crypto/SshPrivateKeyDiscovery.cs @@ -0,0 +1,49 @@ +namespace CrypVol.Lib.Crypto; + +/// 发现当前用户 SSH 目录中的私钥候选文件。 +public static class SshPrivateKeyDiscovery +{ + /// 枚举当前用户 ~/.ssh 目录中的私钥候选文件。 + /// 按文件名排序的候选私钥文件。 + public static IEnumerable DiscoverUserPrivateKeys() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) return []; + return DiscoverPrivateKeys(new DirectoryInfo(Path.Combine(userProfile, ".ssh"))); + } + + /// 枚举指定 SSH 目录中的私钥候选文件。 + /// SSH 配置目录。 + /// 按文件名排序的候选私钥文件。 + public static IEnumerable DiscoverPrivateKeys(DirectoryInfo sshDirectory) + { + ArgumentNullException.ThrowIfNull(sshDirectory); + if (!sshDirectory.Exists) return []; + + try + { + var fileNames = sshDirectory.EnumerateFiles("*", SearchOption.TopDirectoryOnly) + .Select(file => file.Name) + .ToHashSet(StringComparer.Ordinal); + return fileNames + .Where(fileName => IsPairedPrivateKey(fileName, fileNames)) + .Select(fileName => new FileInfo(Path.Combine(sshDirectory.FullName, fileName))) + .OrderBy(file => file.Name, StringComparer.Ordinal) + .ToArray(); + } + catch (UnauthorizedAccessException) + { + return []; + } + catch (IOException) + { + return []; + } + } + + private static bool IsPairedPrivateKey(string fileName, IReadOnlySet fileNames) + { + return !fileName.EndsWith(".pub", StringComparison.OrdinalIgnoreCase) + && fileNames.Contains($"{fileName}.pub"); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Models/BlockMetadata.cs b/CrypVol.Lib/Engine/Models/BlockMetadata.cs index a5ca0e2..7ad9bb2 100644 --- a/CrypVol.Lib/Engine/Models/BlockMetadata.cs +++ b/CrypVol.Lib/Engine/Models/BlockMetadata.cs @@ -31,4 +31,7 @@ public sealed class BlockMetadata /// 是否在该块前写入 FileEntryHeader。 public bool IsFirstFragment { get; init; } -} + + /// File 完整性等级下,由末块承载的原始文件 SHA-256。 + public byte[]? FileHash { get; set; } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Models/DataBlock.cs b/CrypVol.Lib/Engine/Models/DataBlock.cs index 90bcb10..13eeec9 100644 --- a/CrypVol.Lib/Engine/Models/DataBlock.cs +++ b/CrypVol.Lib/Engine/Models/DataBlock.cs @@ -41,6 +41,6 @@ public DataBlock(byte[] buffer, int length, BlockMetadata metadata) public void Dispose() { var buffer = Interlocked.Exchange(ref _buffer, null); - if (buffer is not null) ArrayPool.Shared.Return(buffer); + if (buffer is not null) ArrayPool.Shared.Return(buffer, true); } } \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Models/EngineRecordKey.cs b/CrypVol.Lib/Engine/Models/EngineRecordKey.cs new file mode 100644 index 0000000..61715a4 --- /dev/null +++ b/CrypVol.Lib/Engine/Models/EngineRecordKey.cs @@ -0,0 +1,17 @@ +namespace CrypVol.Lib.Engine.Models; + +/// Engine 运行记录的强类型键。 +/// 记录值类型。 +public sealed class EngineRecordKey +{ + /// 创建强类型记录键。 + /// 记录名称。 + public EngineRecordKey(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + } + + /// 记录名称。 + public string Name { get; } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Models/EngineRecordKeys.cs b/CrypVol.Lib/Engine/Models/EngineRecordKeys.cs new file mode 100644 index 0000000..c4dde34 --- /dev/null +++ b/CrypVol.Lib/Engine/Models/EngineRecordKeys.cs @@ -0,0 +1,9 @@ +namespace CrypVol.Lib.Engine.Models; + +/// 引擎内置处理器使用的标准运行记录键。 +public static class EngineRecordKeys +{ + /// File 完整性校验失败的相对路径集合。 + public static EngineRecordKey> FileIntegrityFailures { get; } = + new("file-integrity-failures"); +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/ProcessingEngine.cs b/CrypVol.Lib/Engine/ProcessingEngine.cs index 08005b2..b6fa009 100644 --- a/CrypVol.Lib/Engine/ProcessingEngine.cs +++ b/CrypVol.Lib/Engine/ProcessingEngine.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading.Channels; using CrypVol.Lib.Engine.Models; using CrypVol.Lib.Engine.Processors; @@ -61,6 +62,13 @@ public void SetRecord(string name, object? value) _records[name] = value; } + /// 使用强类型键记录当前运行可共享的命名值。 + public void SetRecord(EngineRecordKey key, T value) + { + ArgumentNullException.ThrowIfNull(key); + SetRecord(key.Name, value); + } + /// 读取当前运行的命名记录。 /// 记录值类型。 /// 记录名称。 @@ -78,6 +86,13 @@ public bool TryGetRecord(string name, out T? value) return false; } + /// 使用强类型键读取当前运行记录。 + public bool TryGetRecord(EngineRecordKey key, out T? value) + { + ArgumentNullException.ThrowIfNull(key); + return TryGetRecord(key.Name, out value); + } + /// 记录跟踪日志,供各阶段与事件处理器调用。 /// 日志模板。 /// 日志参数。 @@ -86,6 +101,14 @@ public void LogTrace(string message, params object?[] args) _logger?.LogTrace(message, args); } + /// 记录调试日志,供各阶段与事件处理器调用。 + /// 日志模板。 + /// 日志参数。 + public void LogDebug(string message, params object?[] args) + { + _logger?.LogDebug(message, args); + } + /// 记录信息日志,供各阶段与事件处理器调用。 /// 日志模板。 /// 日志参数。 @@ -122,6 +145,9 @@ public async Task StartAsync(CancellationToken cancellationToken = default) using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); try { + LogDebug("引擎准备: Provider={Provider}, Processors=[{Processors}], Receiver={Receiver}", + _provider.GetType().Name, string.Join(" -> ", _processors.Select(processor => processor.GetType().Name)), + _receiver.GetType().Name); await InitializeStagesAsync(linkedCancellation.Token); await ValidateStagesAsync(linkedCancellation.Token); await PrepareStagesAsync(linkedCancellation.Token); @@ -132,7 +158,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) await StartStagesAsync(linkedCancellation.Token); State = ProcessingEngineState.Running; - Started?.Invoke(this, new ProcessingEngineEventArgs()); + InvokeObserverHooks(Started, new ProcessingEngineEventArgs(), nameof(Started)); var tasks = new List(_processors.Count + 2) { @@ -143,7 +169,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) await Task.WhenAll(tasks); State = ProcessingEngineState.Completed; - Completed?.Invoke(this, new ProcessingEngineEventArgs()); + InvokeObserverHooks(Completed, new ProcessingEngineEventArgs(), nameof(Completed)); LogInformation("数据流程完成"); } catch (Exception ex) @@ -152,7 +178,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) State = ex is OperationCanceledException ? ProcessingEngineState.Canceled : ProcessingEngineState.Faulted; - Failed?.Invoke(this, new ProcessingEngineEventArgs(ex)); + InvokeObserverHooks(Failed, new ProcessingEngineEventArgs(ex), nameof(Failed)); LogError(ex, "数据流程失败"); throw; } @@ -165,7 +191,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) } finally { - Stopped?.Invoke(this, new ProcessingEngineEventArgs()); + InvokeObserverHooks(Stopped, new ProcessingEngineEventArgs(), nameof(Stopped)); } } } @@ -187,6 +213,8 @@ private void BindChannels() _receiver.BindChannel(reader); Channels = channels.AsReadOnly(); + LogDebug("引擎已绑定 {ChannelCount} 条有界通道,单通道容量 {ChannelCapacity}", + Channels.Count, ChannelCapacity); } private static Channel CreateChannel() @@ -202,53 +230,84 @@ private static Channel CreateChannel() private Task InitializeStagesAsync(CancellationToken cancellationToken) { return Task.WhenAll([ - _provider.InitializeAsync(this, cancellationToken), - _receiver.InitializeAsync(this, cancellationToken), - .. _processors.Select(p => p.InitializeAsync(this, cancellationToken)) + RunLifecycleAsync("初始化", "Provider", _provider, + () => _provider.InitializeAsync(this, cancellationToken)), + RunLifecycleAsync("初始化", "Receiver", _receiver, + () => _receiver.InitializeAsync(this, cancellationToken)), + .. _processors.Select((processor, index) => RunLifecycleAsync("初始化", $"Processor#{index}", processor, + () => processor.InitializeAsync(this, cancellationToken))) ]); } private Task ValidateStagesAsync(CancellationToken cancellationToken) { return Task.WhenAll([ - _provider.ValidateAsync(cancellationToken), - _receiver.ValidateAsync(cancellationToken), - .. _processors.Select(p => p.ValidateAsync(cancellationToken)) + RunLifecycleAsync("校验", "Provider", _provider, () => _provider.ValidateAsync(cancellationToken)), + RunLifecycleAsync("校验", "Receiver", _receiver, () => _receiver.ValidateAsync(cancellationToken)), + .. _processors.Select((processor, index) => RunLifecycleAsync("校验", $"Processor#{index}", processor, + () => processor.ValidateAsync(cancellationToken))) ]); } private Task PrepareStagesAsync(CancellationToken cancellationToken) { return Task.WhenAll([ - _provider.PrepareAsync(cancellationToken), - _receiver.PrepareAsync(cancellationToken), - .. _processors.Select(p => p.PrepareAsync(cancellationToken)) + RunLifecycleAsync("预处理", "Provider", _provider, () => _provider.PrepareAsync(cancellationToken)), + RunLifecycleAsync("预处理", "Receiver", _receiver, () => _receiver.PrepareAsync(cancellationToken)), + .. _processors.Select((processor, index) => RunLifecycleAsync("预处理", $"Processor#{index}", processor, + () => processor.PrepareAsync(cancellationToken))) ]); } - private Task StartStagesAsync(CancellationToken cancellationToken) + private async Task StartStagesAsync(CancellationToken cancellationToken) { - return Task.WhenAll([ - _provider.StartAsync(cancellationToken), - _receiver.StartAsync(cancellationToken), - .. _processors.Select(p => p.StartAsync(cancellationToken)) - ]); + await RunLifecycleAsync("启动", "Receiver", _receiver, () => _receiver.StartAsync(cancellationToken)); + for (var index = _processors.Count - 1; index >= 0; index--) + { + var processor = _processors[index]; + await RunLifecycleAsync("启动", $"Processor#{index}", processor, + () => processor.StartAsync(cancellationToken)); + } + + await RunLifecycleAsync("启动", "Provider", _provider, () => _provider.StartAsync(cancellationToken)); } private Task DisposeStagesAsync(CancellationToken cancellationToken) { return Task.WhenAll([ - _provider.DisposeAsync(cancellationToken), - _receiver.DisposeAsync(cancellationToken), - .. _processors.Select(p => p.DisposeAsync(cancellationToken)) + RunLifecycleAsync("释放", "Provider", _provider, () => _provider.DisposeAsync(cancellationToken)), + RunLifecycleAsync("释放", "Receiver", _receiver, () => _receiver.DisposeAsync(cancellationToken)), + .. _processors.Select((processor, index) => RunLifecycleAsync("释放", $"Processor#{index}", processor, + () => processor.DisposeAsync(cancellationToken))) ]); } + private async Task RunLifecycleAsync(string phase, string role, IAsyncLifeCycle stage, Func action) + { + var stopwatch = Stopwatch.StartNew(); + LogDebug("{Role} {Stage} 开始{Phase}", role, stage.GetType().Name, phase); + try + { + await action(); + LogDebug("{Role} {Stage} 完成{Phase},耗时 {ElapsedMilliseconds} ms", role, stage.GetType().Name, + phase, stopwatch.ElapsedMilliseconds); + } + catch (Exception exception) + { + LogDebug("{Role} {Stage} {Phase}失败,耗时 {ElapsedMilliseconds} ms,异常 {ExceptionType}: {Message}", + role, stage.GetType().Name, phase, stopwatch.ElapsedMilliseconds, exception.GetType().Name, + exception.Message); + throw; + } + } + private async Task RunProviderAsync(CancellationTokenSource cancellation) { try { + LogDebug("Provider {Provider} 开始执行", _provider.GetType().Name); await _provider.ProduceAsync(cancellation.Token); + LogDebug("Provider {Provider} 已完成", _provider.GetType().Name); } catch (Exception ex) { @@ -261,7 +320,9 @@ private async Task RunProcessorAsync(IDataProcessor processor, int index, Cancel { try { + LogDebug("Processor#{Index} {Processor} 开始执行", index, processor.GetType().Name); await processor.ProcessAsync(cancellation.Token); + LogDebug("Processor#{Index} {Processor} 已完成", index, processor.GetType().Name); } catch (Exception ex) { @@ -274,7 +335,9 @@ private async Task RunReceiverAsync(CancellationTokenSource cancellation) { try { + LogDebug("Receiver {Receiver} 开始执行", _receiver.GetType().Name); await _receiver.ReceiveAsync(cancellation.Token); + LogDebug("Receiver {Receiver} 已完成", _receiver.GetType().Name); } catch (Exception ex) { @@ -291,6 +354,9 @@ private async Task InterruptAsync(CancellationTokenSource cancellation, Exceptio if (Interlocked.Exchange(ref _interrupted, 1) != 0) return; + if (error is null) LogDebug("引擎收到中断请求"); + else LogDebug("引擎因 {ExceptionType} 中断: {Message}", error.GetType().Name, error.Message); + foreach (var channel in Channels) channel.Writer.TryComplete(error); @@ -305,6 +371,26 @@ private void DrainPendingBlocks() block.Dispose(); } + /// 执行观测型引擎事件,避免单个订阅者破坏主流程或掩盖原始异常。 + /// 待执行的事件处理器。 + /// 事件参数。 + /// 事件名称,用于日志定位。 + private void InvokeObserverHooks(EventHandler? handlers, + ProcessingEngineEventArgs eventArgs, string eventName) + { + if (handlers is null) return; + + foreach (EventHandler handler in handlers.GetInvocationList()) + try + { + handler(this, eventArgs); + } + catch (Exception exception) + { + LogError(exception, "引擎观测事件 {EventName} 的处理器失败", eventName); + } + } + public static EngineBuilder Builder() { return new EngineBuilder(); diff --git a/CrypVol.Lib/Engine/ProcessingEngineObserver.cs b/CrypVol.Lib/Engine/ProcessingEngineObserver.cs new file mode 100644 index 0000000..090dfa0 --- /dev/null +++ b/CrypVol.Lib/Engine/ProcessingEngineObserver.cs @@ -0,0 +1,96 @@ +using CrypVol.Lib.Engine.Models; + +namespace CrypVol.Lib.Engine; + +/// 通过 Engine Hook 观察一次运行并捕获指定运行记录。 +public sealed class ProcessingEngineObserver : IDisposable +{ + private readonly ProcessingEngine _engine; + private readonly List> _recordCaptures = []; + private readonly Dictionary _records = new(StringComparer.Ordinal); + + /// 创建并立即订阅引擎事件的观察器。 + /// 被观察的已构造引擎。 + public ProcessingEngineObserver(ProcessingEngine engine) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _engine.Started += OnStarted; + _engine.Completed += OnCompleted; + _engine.Failed += OnFailed; + _engine.Stopped += OnStopped; + } + + /// 引擎实际开始处理的时间。 + public DateTimeOffset? StartedAt { get; private set; } + + /// 引擎停止的时间。 + public DateTimeOffset? StoppedAt { get; private set; } + + /// 失败 Hook 捕获的异常。 + public Exception? Exception { get; private set; } + + /// + public void Dispose() + { + _engine.Started -= OnStarted; + _engine.Completed -= OnCompleted; + _engine.Failed -= OnFailed; + _engine.Stopped -= OnStopped; + } + + /// 在 Completed Hook 中捕获一个强类型运行记录。 + /// 记录值类型。 + /// 待捕获的记录键。 + public void Capture(EngineRecordKey key) + { + ArgumentNullException.ThrowIfNull(key); + _recordCaptures.Add(engine => + { + if (engine.TryGetRecord(key, out var value)) _records[key.Name] = value; + }); + } + + /// 启动被观察引擎。 + /// 取消令牌。 + public Task RunAsync(CancellationToken cancellationToken = default) + { + return _engine.StartAsync(cancellationToken); + } + + /// 读取已由 Completed Hook 捕获的运行记录。 + /// 记录值类型。 + /// 记录键。 + /// 捕获的值。 + /// 是否已捕获且类型匹配。 + public bool TryGetCaptured(EngineRecordKey key, out T? value) + { + if (_records.TryGetValue(key.Name, out var candidate) && candidate is T typed) + { + value = typed; + return true; + } + + value = default; + return false; + } + + private void OnStarted(object? sender, ProcessingEngineEventArgs eventArgs) + { + StartedAt = DateTimeOffset.UtcNow; + } + + private void OnCompleted(object? sender, ProcessingEngineEventArgs eventArgs) + { + foreach (var capture in _recordCaptures) capture(_engine); + } + + private void OnFailed(object? sender, ProcessingEngineEventArgs eventArgs) + { + Exception = eventArgs.Exception; + } + + private void OnStopped(object? sender, ProcessingEngineEventArgs eventArgs) + { + StoppedAt = DateTimeOffset.UtcNow; + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/CompressionProcessor.cs b/CrypVol.Lib/Engine/Processors/CompressionProcessor.cs index a5943f3..99baf37 100644 --- a/CrypVol.Lib/Engine/Processors/CompressionProcessor.cs +++ b/CrypVol.Lib/Engine/Processors/CompressionProcessor.cs @@ -10,31 +10,26 @@ public sealed class CompressionProcessor : DataProcessorBase private readonly CompressionLevel _level; /// 创建压缩处理器。 - /// 压缩级别。 - public CompressionProcessor(int compressionLevel) + /// .NET GZip 压缩预设。 + public CompressionProcessor(CompressionLevel compressionLevel) { - _level = compressionLevel switch - { - 0 => CompressionLevel.NoCompression, - <= 3 => CompressionLevel.Fastest, - <= 6 => CompressionLevel.Optimal, - _ => CompressionLevel.SmallestSize - }; + _level = compressionLevel; } /// protected override ValueTask ProcessBlockAsync(DataBlock block, CancellationToken cancellationToken = default) { - using var stream = new MemoryStream(); + using var stream = new MemoryStream(block.Length + 1024); using (var gzip = new GZipStream(stream, _level, true)) { gzip.Write(block.Buffer, 0, block.Length); } - var data = stream.ToArray(); - var buffer = ArrayPool.Shared.Rent(data.Length); - data.CopyTo(buffer, 0); - return ValueTask.FromResult(new DataBlock(buffer, data.Length, block.Metadata)); + if (!stream.TryGetBuffer(out var segment)) throw new InvalidOperationException("无法读取压缩缓冲区。"); + var length = checked((int)stream.Length); + var buffer = ArrayPool.Shared.Rent(length); + segment.AsSpan(0, length).CopyTo(buffer); + return ValueTask.FromResult(new DataBlock(buffer, length, block.Metadata)); } } \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/DataProcessorBase.cs b/CrypVol.Lib/Engine/Processors/DataProcessorBase.cs index f530f0a..64bf5c1 100644 --- a/CrypVol.Lib/Engine/Processors/DataProcessorBase.cs +++ b/CrypVol.Lib/Engine/Processors/DataProcessorBase.cs @@ -63,12 +63,27 @@ public async Task ProcessAsync(CancellationToken cancellationToken = default) DataBlock? output = null; try { + Engine.LogTrace( + "Processor {Processor} 接收块: {Path}, Seq={Sequence}, Target={TargetIndex}, Length={Length}, Flags=0x{Flags:X2}", + GetType().Name, block.Metadata.RelativePath, block.Metadata.Sequence, block.Metadata.TargetIndex, + block.Length, block.Metadata.Flags); output = await ProcessBlockAsync(block, cancellationToken); + if (ReferenceEquals(output, block)) + throw new InvalidOperationException("Processor 不能将输入 DataBlock 作为输出转交。"); if (output is not null) { + Engine.LogTrace( + "Processor {Processor} 输出块: {Path}, Seq={Sequence}, Target={TargetIndex}, Length={Length}, Flags=0x{Flags:X2}", + GetType().Name, output.Metadata.RelativePath, output.Metadata.Sequence, + output.Metadata.TargetIndex, output.Length, output.Metadata.Flags); await writer.WriteAsync(output, cancellationToken); output = null; } + else + { + Engine.LogTrace("Processor {Processor} 过滤块: {Path}, Seq={Sequence}", GetType().Name, + block.Metadata.RelativePath, block.Metadata.Sequence); + } } finally { @@ -85,6 +100,10 @@ public async Task ProcessAsync(CancellationToken cancellationToken = default) finally { writer.TryComplete(error); + if (error is null) Engine.LogDebug("Processor {Processor} 已完成输出通道", GetType().Name); + else + Engine.LogDebug("Processor {Processor} 因 {ExceptionType} 完成输出通道", GetType().Name, + error.GetType().Name); } } diff --git a/CrypVol.Lib/Engine/Processors/DecompressionProcessor.cs b/CrypVol.Lib/Engine/Processors/DecompressionProcessor.cs index 6038ef2..96c50d8 100644 --- a/CrypVol.Lib/Engine/Processors/DecompressionProcessor.cs +++ b/CrypVol.Lib/Engine/Processors/DecompressionProcessor.cs @@ -7,17 +7,35 @@ namespace CrypVol.Lib.Engine.Processors; /// 仅负责解压 GZip 数据块。 public sealed class DecompressionProcessor : DataProcessorBase { + private const int MaxDecompressedBlockSize = 64 * 1024 * 1024; + /// protected override ValueTask ProcessBlockAsync(DataBlock block, CancellationToken cancellationToken = default) { using var source = new MemoryStream(block.Buffer, 0, block.Length, false); using var gzip = new GZipStream(source, CompressionMode.Decompress); - using var destination = new MemoryStream(); - gzip.CopyTo(destination); - var data = destination.ToArray(); - var buffer = ArrayPool.Shared.Rent(data.Length); - data.CopyTo(buffer, 0); - return ValueTask.FromResult(new DataBlock(buffer, data.Length, block.Metadata)); + using var destination = new MemoryStream(Math.Min(block.Length * 2, MaxDecompressedBlockSize)); + var temp = ArrayPool.Shared.Rent(81920); + try + { + int read; + while ((read = gzip.Read(temp, 0, temp.Length)) > 0) + { + if (destination.Length + read > MaxDecompressedBlockSize) + throw new InvalidDataException("解压后的数据块超过 64MiB 上限。"); + destination.Write(temp, 0, read); + } + + if (!destination.TryGetBuffer(out var segment)) throw new InvalidOperationException("无法读取解压缓冲区。"); + var length = checked((int)destination.Length); + var buffer = ArrayPool.Shared.Rent(length); + segment.AsSpan(0, length).CopyTo(buffer); + return ValueTask.FromResult(new DataBlock(buffer, length, block.Metadata)); + } + finally + { + ArrayPool.Shared.Return(temp, true); + } } } \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/DecryptionProcessor.cs b/CrypVol.Lib/Engine/Processors/DecryptionProcessor.cs index 7003858..a3a2453 100644 --- a/CrypVol.Lib/Engine/Processors/DecryptionProcessor.cs +++ b/CrypVol.Lib/Engine/Processors/DecryptionProcessor.cs @@ -1,7 +1,6 @@ using System.Buffers; using System.Security.Cryptography; using CrypVol.Lib.Engine.Models; -using CrypVol.Lib.Utility; namespace CrypVol.Lib.Engine.Processors; @@ -9,17 +8,14 @@ namespace CrypVol.Lib.Engine.Processors; public sealed class DecryptionProcessor : DataProcessorBase { private readonly byte[] _cek; - private readonly bool _enableCrc32; private readonly bool _rescue; /// 创建解密处理器。 /// 内容加密密钥。 - /// 完整性级别。 /// 校验或解密失败时是否输出零填充数据。 - public DecryptionProcessor(byte[] cek, IntegrityLevel integrityLevel = IntegrityLevel.None, bool rescue = false) + public DecryptionProcessor(byte[] cek, bool rescue = false) { _cek = cek ?? throw new ArgumentNullException(nameof(cek)); - _enableCrc32 = integrityLevel >= IntegrityLevel.Block; _rescue = rescue; } @@ -28,19 +24,6 @@ public DecryptionProcessor(byte[] cek, IntegrityLevel integrityLevel = Integrity CancellationToken cancellationToken = default) { var length = block.Length; - if (_enableCrc32) - { - var expected = BitConverter.ToUInt32(block.Buffer, length - 4); - var actual = Crc32.Compute(block.Buffer.AsSpan(0, length - 4)); - if (expected != actual) - { - if (!_rescue) throw new InvalidDataException($"CRC32 校验失败: 期望 {expected:X8}, 实际 {actual:X8}"); - return ValueTask.FromResult(CreateZeroBlock(block, length - 32)); - } - - length -= 4; - } - var cipherLength = length - 12 - 16; var buffer = ArrayPool.Shared.Rent(cipherLength); try diff --git a/CrypVol.Lib/Engine/Processors/EncryptionProcessor.cs b/CrypVol.Lib/Engine/Processors/EncryptionProcessor.cs index 0a248e9..e0422f1 100644 --- a/CrypVol.Lib/Engine/Processors/EncryptionProcessor.cs +++ b/CrypVol.Lib/Engine/Processors/EncryptionProcessor.cs @@ -1,7 +1,6 @@ using System.Buffers; using System.Security.Cryptography; using CrypVol.Lib.Engine.Models; -using CrypVol.Lib.Utility; namespace CrypVol.Lib.Engine.Processors; @@ -9,15 +8,12 @@ namespace CrypVol.Lib.Engine.Processors; public sealed class EncryptionProcessor : DataProcessorBase { private readonly byte[] _cek; - private readonly bool _enableCrc32; /// 创建加密处理器。 /// 内容加密密钥。 - /// 完整性级别。 - public EncryptionProcessor(byte[] cek, IntegrityLevel integrityLevel = IntegrityLevel.None) + public EncryptionProcessor(byte[] cek) { _cek = cek ?? throw new ArgumentNullException(nameof(cek)); - _enableCrc32 = integrityLevel >= IntegrityLevel.Block; } /// @@ -30,14 +26,11 @@ public EncryptionProcessor(byte[] cek, IntegrityLevel integrityLevel = Integrity using var aes = new AesGcm(_cek, 16); aes.Encrypt(nonce, block.Data.Span, ciphertext, tag); - var crcLength = _enableCrc32 ? 4 : 0; - var length = nonce.Length + ciphertext.Length + tag.Length + crcLength; + var length = nonce.Length + ciphertext.Length + tag.Length; var buffer = ArrayPool.Shared.Rent(length); Buffer.BlockCopy(nonce, 0, buffer, 0, nonce.Length); Buffer.BlockCopy(ciphertext, 0, buffer, nonce.Length, ciphertext.Length); Buffer.BlockCopy(tag, 0, buffer, nonce.Length + ciphertext.Length, tag.Length); - if (_enableCrc32) - BitConverter.GetBytes(Crc32.Compute(buffer.AsSpan(0, length - 4))).CopyTo(buffer, length - 4); return ValueTask.FromResult(new DataBlock(buffer, length, block.Metadata)); } } \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/FileIntegrityVerificationProcessor.cs b/CrypVol.Lib/Engine/Processors/FileIntegrityVerificationProcessor.cs new file mode 100644 index 0000000..244cc36 --- /dev/null +++ b/CrypVol.Lib/Engine/Processors/FileIntegrityVerificationProcessor.cs @@ -0,0 +1,73 @@ +using System.Buffers; +using System.Security.Cryptography; +using CrypVol.Lib.Engine.Models; +using CrypVol.Lib.Volume; + +namespace CrypVol.Lib.Engine.Processors; + +/// 在解密与解压后的原始文件数据上验证 File 级 SHA-256 摘要。 +public sealed class FileIntegrityVerificationProcessor : DataProcessorBase +{ + private readonly bool _continueOnFailure; + private readonly Dictionary _hashes = new(StringComparer.Ordinal); + + /// 创建 File 级摘要验证处理器。 + /// 为 时记录失败并继续处理后续文件。 + public FileIntegrityVerificationProcessor(bool continueOnFailure = false) + { + _continueOnFailure = continueOnFailure; + } + + /// + protected override Task OnInitializeAsync(CancellationToken cancellationToken = default) + { + if (_continueOnFailure) + Engine.SetRecord(EngineRecordKeys.FileIntegrityFailures, new List()); + return Task.CompletedTask; + } + + /// + protected override ValueTask ProcessBlockAsync(DataBlock block, + CancellationToken cancellationToken = default) + { + var path = block.Metadata.RelativePath; + if (!_hashes.TryGetValue(path, out var hash)) + _hashes[path] = hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + hash.AppendData(block.Data.Span); + + if (IsLastFileBlock(block.Metadata)) + { + var expected = block.Metadata.FileHash ?? throw new InvalidDataException("文件末块缺少 SHA-256 摘要。"); + var actual = hash.GetHashAndReset(); + _hashes.Remove(path); + hash.Dispose(); + if (!CryptographicOperations.FixedTimeEquals(expected, actual)) + { + var error = new InvalidDataException($"文件 SHA-256 校验失败: {path}"); + if (!_continueOnFailure) throw error; + if (!Engine.TryGetRecord(EngineRecordKeys.FileIntegrityFailures, out var failures) || failures is null) + throw new InvalidOperationException("File 完整性失败记录未初始化。", error); + failures.Add(path); + Engine.LogWarning("文件 SHA-256 校验失败: {Path}", path); + } + } + + var buffer = ArrayPool.Shared.Rent(block.Length); + block.Data.CopyTo(buffer); + return ValueTask.FromResult(new DataBlock(buffer, block.Length, block.Metadata)); + } + + /// + public override Task DisposeAsync(CancellationToken cancellationToken = default) + { + foreach (var hash in _hashes.Values) hash.Dispose(); + _hashes.Clear(); + return Task.CompletedTask; + } + + /// 判断数据块是否为一个文件的最后一个块。 + private static bool IsLastFileBlock(BlockMetadata metadata) + { + return (metadata.Flags & 3) is (byte)FileEntryHeaderFlagsEnum.Full or (byte)FileEntryHeaderFlagsEnum.CrossTail; + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/IntegrityAppendProcessor.cs b/CrypVol.Lib/Engine/Processors/IntegrityAppendProcessor.cs new file mode 100644 index 0000000..b36375c --- /dev/null +++ b/CrypVol.Lib/Engine/Processors/IntegrityAppendProcessor.cs @@ -0,0 +1,50 @@ +using System.Buffers; +using CrypVol.Lib.Engine.Models; +using CrypVol.Lib.Utility; +using CrypVol.Lib.Volume; + +namespace CrypVol.Lib.Engine.Processors; + +/// 在最终存储的数据块后附加文件摘要和块级 CRC32。 +public sealed class IntegrityAppendProcessor : DataProcessorBase +{ + private readonly IntegrityLevel _level; + + /// 创建完整性附加处理器。 + /// 要写入的完整性等级。 + public IntegrityAppendProcessor(IntegrityLevel level) + { + _level = level; + } + + /// + protected override ValueTask ProcessBlockAsync(DataBlock block, + CancellationToken cancellationToken = default) + { + var appendFileHash = _level >= IntegrityLevel.File && IsLastFileBlock(block.Metadata); + var appendCrc = _level >= IntegrityLevel.Block; + var length = block.Length + (appendFileHash ? 32 : 0) + (appendCrc ? sizeof(uint) : 0); + var buffer = ArrayPool.Shared.Rent(length); + block.Data.CopyTo(buffer); + var position = block.Length; + + if (appendFileHash) + { + var hash = block.Metadata.FileHash ?? throw new InvalidDataException("文件末块缺少 SHA-256 摘要。"); + if (hash.Length != 32) throw new InvalidDataException("文件 SHA-256 摘要长度无效。"); + hash.CopyTo(buffer, position); + position += hash.Length; + } + + if (appendCrc) + BitConverter.GetBytes(Crc32.Compute(buffer.AsSpan(0, position))).CopyTo(buffer, position); + + return ValueTask.FromResult(new DataBlock(buffer, length, block.Metadata)); + } + + /// 判断数据块是否为一个文件的最后一个块。 + private static bool IsLastFileBlock(BlockMetadata metadata) + { + return (metadata.Flags & 3) is (byte)FileEntryHeaderFlagsEnum.Full or (byte)FileEntryHeaderFlagsEnum.CrossTail; + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Processors/IntegrityStripProcessor.cs b/CrypVol.Lib/Engine/Processors/IntegrityStripProcessor.cs new file mode 100644 index 0000000..2c183ed --- /dev/null +++ b/CrypVol.Lib/Engine/Processors/IntegrityStripProcessor.cs @@ -0,0 +1,52 @@ +using System.Buffers; +using CrypVol.Lib.Engine.Models; +using CrypVol.Lib.Utility; +using CrypVol.Lib.Volume; + +namespace CrypVol.Lib.Engine.Processors; + +/// 校验并剥离存储块末尾的 CRC32 与文件 SHA-256 摘要。 +public sealed class IntegrityStripProcessor : DataProcessorBase +{ + private readonly IntegrityLevel _level; + + /// 创建完整性剥离处理器。 + /// 卷头记录的完整性等级。 + public IntegrityStripProcessor(IntegrityLevel level) + { + _level = level; + } + + /// + protected override ValueTask ProcessBlockAsync(DataBlock block, + CancellationToken cancellationToken = default) + { + var length = block.Length; + if (_level >= IntegrityLevel.Block) + { + if (length < sizeof(uint)) throw new InvalidDataException("数据块缺少 CRC32。"); + var crcOffset = length - sizeof(uint); + var expected = BitConverter.ToUInt32(block.Buffer, crcOffset); + var actual = Crc32.Compute(block.Buffer.AsSpan(0, crcOffset)); + if (expected != actual) throw new InvalidDataException($"CRC32 校验失败: 期望 {expected:X8}, 实际 {actual:X8}"); + length = crcOffset; + } + + if (_level >= IntegrityLevel.File && IsLastFileBlock(block.Metadata)) + { + if (length < 32) throw new InvalidDataException("文件末块缺少 SHA-256 摘要。"); + block.Metadata.FileHash = block.Buffer.AsSpan(length - 32, 32).ToArray(); + length -= 32; + } + + var buffer = ArrayPool.Shared.Rent(length); + block.Buffer.AsSpan(0, length).CopyTo(buffer); + return ValueTask.FromResult(new DataBlock(buffer, length, block.Metadata)); + } + + /// 判断数据块是否为一个文件的最后一个块。 + private static bool IsLastFileBlock(BlockMetadata metadata) + { + return (metadata.Flags & 3) is (byte)FileEntryHeaderFlagsEnum.Full or (byte)FileEntryHeaderFlagsEnum.CrossTail; + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Providers/CvpFileDataProvider.cs b/CrypVol.Lib/Engine/Providers/CvpFileDataProvider.cs index 917043a..a7db71d 100644 --- a/CrypVol.Lib/Engine/Providers/CvpFileDataProvider.cs +++ b/CrypVol.Lib/Engine/Providers/CvpFileDataProvider.cs @@ -42,7 +42,7 @@ protected override async Task ProduceCoreAsync(CancellationToken cancellationTok } } - Engine.LogTrace("CVP 数据提供完成: {BlockCount} 块", _blocks.Count); + Engine.LogDebug("CVP 数据提供完成: {BlockCount} 块", _blocks.Count); } /// @@ -67,6 +67,8 @@ private async Task ReadAsync(BlockMetadata metadata, CancellationToke { _stream!.Position = metadata.SourceOffset; var read = await _stream.ReadAsync(buffer.AsMemory(0, metadata.Length), cancellationToken); + if (read != metadata.Length) + throw new EndOfStreamException($"CVP 数据块读取不完整: {metadata.SourceFullPath} offset={metadata.SourceOffset:X}"); Engine.LogTrace("CVP读: {Path}#{Sequence} offset={Offset:X} {Bytes}字节", metadata.RelativePath, metadata.Sequence, metadata.SourceOffset, read); return new DataBlock(buffer, read, metadata); diff --git a/CrypVol.Lib/Engine/Providers/DataProviderBase.cs b/CrypVol.Lib/Engine/Providers/DataProviderBase.cs index 8a04715..ce246a2 100644 --- a/CrypVol.Lib/Engine/Providers/DataProviderBase.cs +++ b/CrypVol.Lib/Engine/Providers/DataProviderBase.cs @@ -65,16 +65,24 @@ public async Task ProduceAsync(CancellationToken cancellationToken = default) finally { writer.TryComplete(error); + if (error is null) Engine.LogDebug("Provider {Provider} 已完成输出通道", GetType().Name); + else + Engine.LogDebug("Provider {Provider} 因 {ExceptionType} 完成输出通道", GetType().Name, + error.GetType().Name); } } /// 写出一个数据块;反压由 Engine 创建的有界通道提供。 /// 待写出的数据块。 /// 取消令牌。 - protected Task WriteAsync(DataBlock block, CancellationToken cancellationToken = default) + protected async Task WriteAsync(DataBlock block, CancellationToken cancellationToken = default) { - return (_writer ?? throw new InvalidOperationException("Provider 尚未绑定输出通道。")) - .WriteAsync(block, cancellationToken).AsTask(); + Engine.LogTrace( + "Provider {Provider} 输出块: {Path}, Seq={Sequence}, Target={TargetIndex}, Offset={Offset}, Length={Length}, Flags=0x{Flags:X2}", + GetType().Name, block.Metadata.RelativePath, block.Metadata.Sequence, block.Metadata.TargetIndex, + block.Metadata.SourceOffset, block.Length, block.Metadata.Flags); + await (_writer ?? throw new InvalidOperationException("Provider 尚未绑定输出通道.")) + .WriteAsync(block, cancellationToken); } /// Engine 注入后初始化阶段私有状态。 diff --git a/CrypVol.Lib/Engine/Providers/SourceFileDataProvider.cs b/CrypVol.Lib/Engine/Providers/SourceFileDataProvider.cs index 6ebf00d..eca77b2 100644 --- a/CrypVol.Lib/Engine/Providers/SourceFileDataProvider.cs +++ b/CrypVol.Lib/Engine/Providers/SourceFileDataProvider.cs @@ -1,4 +1,6 @@ using System.Buffers; +using System.Security.Cryptography; +using System.Text; using CrypVol.Lib.Engine.Models; using CrypVol.Lib.Volume; @@ -8,23 +10,23 @@ namespace CrypVol.Lib.Engine.Providers; public sealed class SourceFileDataProvider : DataProviderBase { private const int LengthFieldSize = sizeof(int); - private readonly IReadOnlyList _files; - private readonly DirectoryInfo _sourceDirectory; private readonly int _chunkSize; - private readonly long _volumeCapacity; + private readonly bool _enableCompression; + private readonly IReadOnlyList _files; private readonly int _headerSize; private readonly IntegrityLevel _integrityLevel; - private readonly bool _enableCompression; + private readonly DirectoryInfo _sourceDirectory; + private readonly long _volumeCapacity; + private long _currentSequence; private int _currentVolume; private long _currentVolumeUsed; - private long _currentSequence; /// 创建流式源文件提供者。 public SourceFileDataProvider(IEnumerable files, DirectoryInfo sourceDirectory, int chunkSize, long volumeCapacity, int headerSize, IntegrityLevel integrityLevel, bool enableCompression) { _files = files?.OrderBy(file => file.FullName, StringComparer.Ordinal).ToArray() - ?? throw new ArgumentNullException(nameof(files)); + ?? throw new ArgumentNullException(nameof(files)); _sourceDirectory = sourceDirectory ?? throw new ArgumentNullException(nameof(sourceDirectory)); _chunkSize = chunkSize; _volumeCapacity = volumeCapacity; @@ -50,22 +52,26 @@ protected override async Task ProduceCoreAsync(CancellationToken cancellationTok { foreach (var file in _files) await ProduceFileAsync(file, cancellationToken); - Engine.LogTrace("源文件数据提供完成: {FileCount} 文件,最后卷 {VolumeIndex}", _files.Count, _currentVolume); + Engine.LogDebug("源文件数据提供完成: {FileCount} 文件,最后卷 {VolumeIndex}", _files.Count, _currentVolume); } private async Task ProduceFileAsync(FileInfo file, CancellationToken cancellationToken) { var relativePath = Path.GetRelativePath(_sourceDirectory.FullName, file.FullName); - if (System.Text.Encoding.UTF8.GetByteCount(relativePath) > 230) + VolumePathSafety.ValidateRelativePath(relativePath); + Engine.LogDebug("源文件读取开始: {Path}, {Length} 字节", relativePath, file.Length); + if (Encoding.UTF8.GetByteCount(relativePath) > 230) throw new InvalidOperationException($"文件相对路径超过 CVP 头限制: {relativePath}"); if (file.Length == 0) { await WriteBlockAsync(ArrayPool.Shared.Rent(0), 0, file, relativePath, 0, true, true, - cancellationToken); + SHA256.HashData([]), cancellationToken); + Engine.LogDebug("源文件读取完成: {Path}, 0 字节", relativePath); return; } + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); await using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, _chunkSize, FileOptions.SequentialScan); long offset = 0; @@ -78,8 +84,10 @@ await WriteBlockAsync(ArrayPool.Shared.Rent(0), 0, file, relativePath, 0, { var read = await ReadAtLeastAsync(stream, buffer, requested, cancellationToken); if (read == 0) throw new EndOfStreamException($"读取源文件时遇到意外结尾: {file.FullName}"); + hash.AppendData(buffer, 0, read); + var isLast = offset + read >= file.Length; await WriteBlockAsync(buffer, read, file, relativePath, offset, isFirstFileBlock, - offset + read >= file.Length, cancellationToken); + isLast, isLast ? hash.GetHashAndReset() : null, cancellationToken); buffer = null!; offset += read; isFirstFileBlock = false; @@ -89,10 +97,12 @@ await WriteBlockAsync(buffer, read, file, relativePath, offset, isFirstFileBlock if (buffer is not null) ArrayPool.Shared.Return(buffer); } } + + Engine.LogDebug("源文件读取完成: {Path}, {Length} 字节", relativePath, file.Length); } private async Task WriteBlockAsync(byte[] buffer, int length, FileInfo file, string relativePath, long offset, - bool isFirstFileBlock, bool isLastFileBlock, CancellationToken cancellationToken) + bool isFirstFileBlock, bool isLastFileBlock, byte[]? fileHash, CancellationToken cancellationToken) { var startsVolumeEntry = true; var initialCost = LengthFieldSize + length + _headerSize; @@ -104,8 +114,10 @@ private async Task WriteBlockAsync(byte[] buffer, int length, FileInfo file, str } var fragmentFlags = isFirstFileBlock - ? (isLastFileBlock ? FileEntryHeaderFlagsEnum.Full : FileEntryHeaderFlagsEnum.CrossHead) - : (isLastFileBlock ? FileEntryHeaderFlagsEnum.CrossTail : FileEntryHeaderFlagsEnum.CrossMid); + ? isLastFileBlock ? FileEntryHeaderFlagsEnum.Full : FileEntryHeaderFlagsEnum.CrossHead + : isLastFileBlock + ? FileEntryHeaderFlagsEnum.CrossTail + : FileEntryHeaderFlagsEnum.CrossMid; var flags = (byte)fragmentFlags; flags |= (byte)(((int)_integrityLevel & 3) << 3); if (_enableCompression) flags |= (byte)FileEntryHeaderFlagsEnum.Compressed; @@ -119,7 +131,8 @@ private async Task WriteBlockAsync(byte[] buffer, int length, FileInfo file, str Length = length, TotalFileSize = file.Length, Flags = flags, - IsFirstFragment = startsVolumeEntry + IsFirstFragment = startsVolumeEntry, + FileHash = fileHash }; try @@ -148,4 +161,4 @@ private static async Task ReadAtLeastAsync(FileStream stream, byte[] buffer return total; } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Receivers/CvpVolumeDataReceiver.cs b/CrypVol.Lib/Engine/Receivers/CvpVolumeDataReceiver.cs index 017a666..b4494fe 100644 --- a/CrypVol.Lib/Engine/Receivers/CvpVolumeDataReceiver.cs +++ b/CrypVol.Lib/Engine/Receivers/CvpVolumeDataReceiver.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using CrypVol.Lib.Volume; @@ -59,6 +60,8 @@ public CvpFileReciver(byte[]? cek, Func outputPathFactory) protected override async Task ReceiveVolumeAsync(VolumeContext context, CancellationToken cancellationToken) { FileStream? stream = null; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var integrityLevel = IntegrityLevel.None; try { long position = 0; @@ -73,9 +76,11 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella Directory.CreateDirectory(directory); stream = new FileStream(context.OutputPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096 * 16, FileOptions.SequentialScan); - Engine.LogTrace("创建卷: {Path}", context.OutputPath); + Engine.LogDebug("创建卷: {Path}", context.OutputPath); } + integrityLevel = (IntegrityLevel)(block.Metadata.Flags >> 3 & 3); + // 一个新卷中的首块必须有文件头,即使它是跨卷文件的续片。 if (totalBlocks == 0 || block.Metadata.IsFirstFragment) { @@ -93,6 +98,7 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella : header.ToBytes(); stream.Position = position; await stream.WriteAsync(headerBytes, cancellationToken); + hash.AppendData(headerBytes); position += headerBytes.Length; Engine.LogTrace("卷头: {Path} ({HeaderType})", block.Metadata.RelativePath, _cek is not null ? "CVPE加密" : "CVPH明文"); @@ -102,6 +108,8 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella stream.Position = position; await stream.WriteAsync(length, cancellationToken); await stream.WriteAsync(block.Data, cancellationToken); + hash.AppendData(length); + hash.AppendData(block.Data.Span); position += sizeof(int) + block.Length; totalBlocks++; } @@ -110,7 +118,13 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella block.Dispose(); } - Engine.LogTrace("卷写入完成: {Path} {Blocks}块 {Bytes}字节", + if (stream is not null && integrityLevel >= IntegrityLevel.Volume) + { + var footer = VolumeIntegrityFooter.Create(hash.GetHashAndReset()); + await stream.WriteAsync(footer, cancellationToken); + } + + Engine.LogDebug("卷写入完成: {Path} {Blocks}块 {Bytes}字节", context.OutputPath, totalBlocks, position); } finally @@ -135,4 +149,4 @@ private static ulong Fnv1AHash64(string input) return hash; } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Receivers/DataReceiverBase.cs b/CrypVol.Lib/Engine/Receivers/DataReceiverBase.cs index a6bc247..17056e2 100644 --- a/CrypVol.Lib/Engine/Receivers/DataReceiverBase.cs +++ b/CrypVol.Lib/Engine/Receivers/DataReceiverBase.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Threading.Channels; using CrypVol.Lib.Engine.Models; @@ -54,10 +55,18 @@ public virtual Task DisposeAsync(CancellationToken cancellationToken = default) /// 读取 Engine 绑定的最终数据流。 /// 取消令牌。 /// 最终数据块异步流。 - protected IAsyncEnumerable ReadAsync(CancellationToken cancellationToken = default) + protected async IAsyncEnumerable ReadAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) { - return (_reader ?? throw new InvalidOperationException("Receiver 尚未绑定输入通道。")) - .ReadAllAsync(cancellationToken); + var reader = _reader ?? throw new InvalidOperationException("Receiver 尚未绑定输入通道。"); + await foreach (var block in reader.ReadAllAsync(cancellationToken)) + { + Engine.LogTrace( + "Receiver {Receiver} 接收块: {Path}, Seq={Sequence}, Target={TargetIndex}, Length={Length}, Flags=0x{Flags:X2}", + GetType().Name, block.Metadata.RelativePath, block.Metadata.Sequence, block.Metadata.TargetIndex, + block.Length, block.Metadata.Flags); + yield return block; + } } /// Engine 注入后初始化阶段私有状态。 diff --git a/CrypVol.Lib/Engine/Receivers/FileDataReceiver.cs b/CrypVol.Lib/Engine/Receivers/FileDataReceiver.cs index e1aac92..d159199 100644 --- a/CrypVol.Lib/Engine/Receivers/FileDataReceiver.cs +++ b/CrypVol.Lib/Engine/Receivers/FileDataReceiver.cs @@ -1,3 +1,5 @@ +using CrypVol.Lib.Volume; + namespace CrypVol.Lib.Engine.Receivers; /// 将处理后的数据块写入普通文件的接收阶段。 @@ -18,9 +20,7 @@ public DataFileReciver(string outputDirectory, bool overwrite) /// protected override async Task ReceiveVolumeAsync(VolumeContext context, CancellationToken cancellationToken) { - var fullPath = context.OutputPath; - if (!Path.IsPathFullyQualified(fullPath)) - fullPath = Path.Combine(_outputDirectory, fullPath); + var fullPath = VolumePathSafety.ResolveUnderRoot(_outputDirectory, context.OutputPath); if (!_overwrite && File.Exists(fullPath)) { @@ -45,7 +45,7 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella { stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096 * 16, FileOptions.SequentialScan); - Engine.LogTrace("创建文件: {Path}", fullPath); + Engine.LogDebug("创建文件: {Path}", fullPath); } stream.Position = position; @@ -58,7 +58,7 @@ protected override async Task ReceiveVolumeAsync(VolumeContext context, Cancella block.Dispose(); } - Engine.LogTrace("文件写入完成: {Path} {Bytes}字节", fullPath, totalBytes); + Engine.LogDebug("文件写入完成: {Path} {Bytes}字节", fullPath, totalBytes); } finally { diff --git a/CrypVol.Lib/Engine/Receivers/NullDataReceiver.cs b/CrypVol.Lib/Engine/Receivers/NullDataReceiver.cs new file mode 100644 index 0000000..8757706 --- /dev/null +++ b/CrypVol.Lib/Engine/Receivers/NullDataReceiver.cs @@ -0,0 +1,12 @@ +namespace CrypVol.Lib.Engine.Receivers; + +/// 消费并释放全部数据块、不产生外部输出的接收阶段。 +public sealed class NullDataReceiver : DataReceiverBase +{ + /// + public override async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + await foreach (var block in ReadAsync(cancellationToken)) + block.Dispose(); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/Receivers/VolumeDataReceiverBase.cs b/CrypVol.Lib/Engine/Receivers/VolumeDataReceiverBase.cs index 5229250..a184da1 100644 --- a/CrypVol.Lib/Engine/Receivers/VolumeDataReceiverBase.cs +++ b/CrypVol.Lib/Engine/Receivers/VolumeDataReceiverBase.cs @@ -3,8 +3,8 @@ namespace CrypVol.Lib.Engine.Receivers; /// 按目标编号路由到多个输出卷的接收阶段基类。 public abstract class VolumeDataReceiverBase : DataReceiverBase { - private readonly Dictionary _volumes = new(); private readonly Dictionary _receiveTasks = new(); + private readonly Dictionary _volumes = new(); /// 已注册或运行时动态创建的输出卷。 protected IEnumerable Targets => _volumes.Values; @@ -28,7 +28,10 @@ public VolumeDataReceiverBase AddTarget(int index, string outputPath) /// /// 目标卷编号。 /// 新建的卷上下文;不支持该目标时返回 。 - protected virtual VolumeContext? CreateTarget(int index) => null; + protected virtual VolumeContext? CreateTarget(int index) + { + return null; + } /// public override async Task ReceiveAsync(CancellationToken cancellationToken = default) @@ -48,6 +51,8 @@ public override async Task ReceiveAsync(CancellationToken cancellationToken = de if (context is not null) { _volumes.Add(context.VolumeIndex, context); + Engine.LogDebug("Receiver {Receiver} 创建目标 {TargetIndex}: {OutputPath}", GetType().Name, + context.VolumeIndex, context.OutputPath); StartReceiver(context, receiveCancellation); } } @@ -56,6 +61,9 @@ public override async Task ReceiveAsync(CancellationToken cancellationToken = de { try { + Engine.LogTrace("Receiver {Receiver} 路由块: {Path}, Seq={Sequence}, Target={TargetIndex}", + GetType().Name, block.Metadata.RelativePath, block.Metadata.Sequence, + context.VolumeIndex); await context.OutputChannel.Writer.WriteAsync(block, receiveCancellation.Token); } catch @@ -97,6 +105,8 @@ public override async Task ReceiveAsync(CancellationToken cancellationToken = de /// 启动单个卷的写入任务,并在失败时中断整个接收阶段。 private void StartReceiver(VolumeContext context, CancellationTokenSource receiveCancellation) { + Engine.LogDebug("Receiver {Receiver} 启动目标 {TargetIndex}: {OutputPath}", GetType().Name, + context.VolumeIndex, context.OutputPath); var task = ReceiveVolumeAsync(context, receiveCancellation.Token); _receiveTasks.Add(context.VolumeIndex, task); _ = task.ContinueWith( @@ -110,4 +120,4 @@ private void StartReceiver(VolumeContext context, CancellationTokenSource receiv /// 卷上下文。 /// 取消令牌。 protected abstract Task ReceiveVolumeAsync(VolumeContext context, CancellationToken cancellationToken); -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Engine/VolumeContext.cs b/CrypVol.Lib/Engine/VolumeContext.cs index 4b2a3ad..34a407b 100644 --- a/CrypVol.Lib/Engine/VolumeContext.cs +++ b/CrypVol.Lib/Engine/VolumeContext.cs @@ -21,4 +21,4 @@ public sealed class VolumeContext SingleReader = true, SingleWriter = true }); -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Helper/CrypVolHelper.cs b/CrypVol.Lib/Helper/CrypVolHelper.cs index 862a12c..5339066 100644 --- a/CrypVol.Lib/Helper/CrypVolHelper.cs +++ b/CrypVol.Lib/Helper/CrypVolHelper.cs @@ -1,3 +1,4 @@ +using CrypVol.Lib.Crypto; using CrypVol.Lib.Engine; using CrypVol.Lib.Engine.Models; using CrypVol.Lib.Engine.Processors; @@ -29,25 +30,34 @@ public async Task PackAsync(PackOptions opts, CancellationToken toke { var prefix = opts.OutputPrefix; if (opts.SourceFiles.Count == 0) - return new PackResult { Error = "无工作项" }; + return new PackResult + { + Error = "无工作项" + }; if (opts.ChunkSizeMb is < 1 or > 64) - return new PackResult { Error = "块大小必须在 1–64 MiB 之间" }; + return new PackResult + { + Error = "块大小必须在 1–64 MiB 之间" + }; var capacity = checked((long)opts.VolumeSizeMb * 1024 * 1024); var (mode, cek) = opts.Credentials; var headerSize = mode is EncryptionMode.None ? FileEntryHeader.HeaderSize : FileEntryHeader.EncryptedHeaderSize; var maxChunkSize = capacity - headerSize - sizeof(int); if (maxChunkSize <= 0) - return new PackResult { Error = "卷容量不足以容纳文件头和数据块长度字段" }; + return new PackResult + { + Error = "卷容量不足以容纳文件头和数据块长度字段" + }; var chunkSize = (int)Math.Min(checked((long)opts.ChunkSizeMb * 1024 * 1024), maxChunkSize); - var compressing = mode != EncryptionMode.None && opts.EnableCompression; + var compressing = opts.EnableCompression; Logger?.LogInformation( "Pack 开始: 流式块大小 {ChunkSize} 字节, {FileCount} 文件, {TotalBytes} 字节, 加密={Encrypted}, 压缩={Compressed}", chunkSize, opts.SourceFiles.Count, opts.SourceFiles.Sum(f => f.Length), mode != EncryptionMode.None ? "是" : "否", - compressing ? $"是 (L{opts.CompressionLevel})" : "否"); + compressing ? $"是 ({opts.CompressionLevel})" : "否"); var encryptHeaders = mode != EncryptionMode.None; @@ -56,9 +66,11 @@ public async Task PackAsync(PackOptions opts, CancellationToken toke headerSize, opts.IntegrityLevel, opts.EnableCompression); var builder = ProcessingEngine.Builder().UseProvider(provider); if (opts.EnableCompression) builder.AddProcessor(new CompressionProcessor(opts.CompressionLevel)); - if (mode is not EncryptionMode.None) builder.AddProcessor(new EncryptionProcessor(cek, opts.IntegrityLevel)); + if (mode is not EncryptionMode.None) builder.AddProcessor(new EncryptionProcessor(cek)); + if (opts.IntegrityLevel is not IntegrityLevel.None) + builder.AddProcessor(new IntegrityAppendProcessor(opts.IntegrityLevel)); var pipeline = builder.UseReceiver(receiver).WithLogger(Logger).Build(); - await pipeline.StartAsync(token); + await RunPipelineAsync(pipeline, token); var totalBytes = opts.SourceFiles.Sum(f => f.Length); var volumePaths = receiver.VolumePaths; @@ -137,7 +149,7 @@ public async Task ExtractAsync(ExtractOptions opts, CancellationT Length = f.BlockSize, TotalFileSize = f.TotalFileSize, Flags = f.Flags, - IsFirstFragment = f.IsFirst + IsFirstFragment = f.StartsEntry }); } @@ -166,10 +178,12 @@ public async Task ExtractAsync(ExtractOptions opts, CancellationT if (!opts.OutputDir.Exists) opts.OutputDir.Create(); var builder = ProcessingEngine.Builder().UseProvider(new CvpFileDataProvider(items)); - if (mode is not EncryptionMode.None) builder.AddProcessor(new DecryptionProcessor(cek, integrityLevel)); + if (integrityLevel is not IntegrityLevel.None) builder.AddProcessor(new IntegrityStripProcessor(integrityLevel)); + if (mode is not EncryptionMode.None) builder.AddProcessor(new DecryptionProcessor(cek)); if (isCompressed) builder.AddProcessor(new DecompressionProcessor()); + if (integrityLevel >= IntegrityLevel.File) builder.AddProcessor(new FileIntegrityVerificationProcessor()); var pipeline = builder.UseReceiver(receiver).WithLogger(Logger).Build(); - await pipeline.StartAsync(token); + await RunPipelineAsync(pipeline, token); Logger?.LogInformation("提取完成: {FileCount} 文件", fileFragments.Count); return new ExtractResult @@ -197,7 +211,7 @@ public async Task BrowseAsync(BrowseOptions opts, CancellationToke { try { - var (_, cek) = opts.Credentials; + var (mode, cek) = opts.Credentials; var filter = BuildGlobMatcher(opts.IncludePattern, opts.ExcludePattern); var scanResult = VolumeScanner.Scan(opts.VolumeFiles, cek, filter.IsActive ? filter : null, Logger); @@ -270,27 +284,31 @@ public async Task ConvertAsync(ConvertOptions opts, CancellationT }; var fileFragments = scanResult.Files; - var items = new List(); + var items = new List(scanResult.OrderedBlocks.Count); var volMap = new Dictionary(); + var sequenceByVolume = new Dictionary(); var newVolIdx = 0; - foreach (var (relPath, fragments) in fileFragments) - foreach (var f in fragments) + foreach (var scannedBlock in scanResult.OrderedBlocks) { + var relPath = scannedBlock.RelativePath; + var f = scannedBlock.Fragment; var oldVol = Path.GetFileNameWithoutExtension(f.CvpFile.Name).Split('.').Last(); if (!int.TryParse(oldVol, out var ov)) ov = 0; if (!volMap.TryGetValue(ov, out var nv)) volMap[ov] = nv = newVolIdx++; + var sequence = sequenceByVolume.TryGetValue(nv, out var currentSequence) ? currentSequence : 0; + sequenceByVolume[nv] = sequence + 1; items.Add(new BlockMetadata { RelativePath = relPath, SourceFullPath = f.CvpFile.FullName, TargetIndex = nv, - Sequence = items.Count(i => i.TargetIndex == nv), + Sequence = sequence, SourceOffset = f.CvpOffset, Length = f.BlockSize, TotalFileSize = f.TotalFileSize, Flags = f.Flags, - IsFirstFragment = f.IsFirst + IsFirstFragment = f.StartsEntry }); } @@ -317,10 +335,12 @@ public async Task ConvertAsync(ConvertOptions opts, CancellationT var builder = ProcessingEngine.Builder() .UseProvider(new CvpFileDataProvider(items)); - if (mode is not EncryptionMode.None) builder.AddProcessor(new DecryptionProcessor(cek, integrityLevel)); - if (newMode is not EncryptionMode.None) builder.AddProcessor(new EncryptionProcessor(newCek, integrityLevel)); + if (integrityLevel is not IntegrityLevel.None) builder.AddProcessor(new IntegrityStripProcessor(integrityLevel)); + if (mode is not EncryptionMode.None) builder.AddProcessor(new DecryptionProcessor(cek)); + if (newMode is not EncryptionMode.None) builder.AddProcessor(new EncryptionProcessor(newCek)); + if (integrityLevel is not IntegrityLevel.None) builder.AddProcessor(new IntegrityAppendProcessor(integrityLevel)); var pipeline = builder.UseReceiver(receiver).WithLogger(Logger).Build(); - await pipeline.StartAsync(token); + await RunPipelineAsync(pipeline, token); var volPaths = Enumerable.Range(0, newVolIdx) .Select(i => Path.Combine(opts.OutputDir.FullName, $"{opts.OutputPrefix}.{i}.cvp")).ToList(); @@ -350,7 +370,7 @@ public async Task VerifyAsync(VerifyOptions opts, CancellationToke { try { - var (_, cek) = opts.Credentials; + var (mode, cek) = opts.Credentials; var filter = BuildGlobMatcher(opts.IncludePattern, opts.ExcludePattern); var scanResult = VolumeScanner.Scan(opts.VolumeFiles, cek, filter.IsActive ? filter : null, Logger); @@ -370,10 +390,24 @@ public async Task VerifyAsync(VerifyOptions opts, CancellationToke Error = "未发现可识别的文件条目" }; + var formatProfiles = fileFragments.Values.SelectMany(fragments => fragments) + .Select(fragment => ( + Integrity: (IntegrityLevel)(fragment.Flags >> 3 & 3), + Compressed: (fragment.Flags & (byte)FileEntryHeaderFlagsEnum.Compressed) != 0)) + .Distinct() + .ToList(); + if (formatProfiles.Count != 1) + return new VerifyResult + { + Error = "输入卷的完整性或压缩配置不一致,无法作为同一归档进行校验。" + }; + var formatProfile = formatProfiles[0]; + var totalBlocks = 0; var corruptedBlocks = 0; var corruptedFiles = 0; var corruptedEntries = new List(); + var blockCorruptedPaths = new HashSet(StringComparer.Ordinal); Logger?.LogInformation("校验开始: {FileCount} 文件, Quick={Quick}", fileFragments.Count, opts.Quick); @@ -413,6 +447,7 @@ public async Task VerifyAsync(VerifyOptions opts, CancellationToke corruptedBlocks++; corruptedEntries.Add(new CorruptedBlock { + VolumePath = f.CvpFile.FullName, FilePath = path, CvpOffset = f.CvpOffset, BlockSize = f.BlockSize @@ -421,11 +456,82 @@ public async Task VerifyAsync(VerifyOptions opts, CancellationToke if (!fileCorrupted) { fileCorrupted = true; + blockCorruptedPaths.Add(path); corruptedFiles++; } } } + if (!opts.Quick) + { + var items = formatProfile.Integrity >= IntegrityLevel.File + ? fileFragments + .Where(entry => !blockCorruptedPaths.Contains(entry.Key)) + .SelectMany(entry => entry.Value.Select((fragment, sequence) => + CreateVerificationItem(entry.Key, fragment, sequence))) + .ToList() + : []; + if (items.Count > 0) + { + var builder = ProcessingEngine.Builder() + .UseProvider(new CvpFileDataProvider(items)) + .AddProcessor(new IntegrityStripProcessor(formatProfile.Integrity)); + if (mode is not EncryptionMode.None) builder.AddProcessor(new DecryptionProcessor(cek)); + if (formatProfile.Compressed) builder.AddProcessor(new DecompressionProcessor()); + var pipeline = builder.AddProcessor(new FileIntegrityVerificationProcessor(true)) + .UseReceiver(new NullDataReceiver()) + .WithLogger(Logger) + .Build(); + using var observer = new ProcessingEngineObserver(pipeline); + observer.Capture(EngineRecordKeys.FileIntegrityFailures); + try + { + await observer.RunAsync(token); + } + catch (Exception ex) + { + throw new InvalidDataException("File 完整性验证引擎失败。", observer.Exception ?? ex); + } + + if (observer.TryGetCaptured(EngineRecordKeys.FileIntegrityFailures, + out var failedPaths) && failedPaths is not null) + foreach (var path in failedPaths) + { + var last = fileFragments[path][^1]; + corruptedBlocks++; + corruptedFiles++; + corruptedEntries.Add(new CorruptedBlock + { + VolumePath = last.CvpFile.FullName, + FilePath = path, + CvpOffset = last.CvpOffset, + BlockSize = last.BlockSize + }); + } + } + } + + foreach (var volume in opts.VolumeFiles) + { + var requiresVolumeHash = fileFragments.Values.SelectMany(fragments => fragments) + .Any(fragment => string.Equals(fragment.CvpFile.FullName, volume.FullName, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) && + (IntegrityLevel)(fragment.Flags >> 3 & 3) >= IntegrityLevel.Volume); + if (!requiresVolumeHash || VolumeIntegrityFooter.Verify(volume.FullName)) continue; + + corruptedBlocks++; + corruptedFiles++; + corruptedEntries.Add(new CorruptedBlock + { + VolumePath = volume.FullName, + FilePath = "[卷尾 SHA-256]", + CvpOffset = 0, + BlockSize = 0 + }); + } + Logger?.LogInformation("校验完成: 总计{TotalBlocks}块, 损坏{CorruptedBlocks}块({CorruptedFiles}文件)", totalBlocks, corruptedBlocks, corruptedFiles); if (corruptedBlocks > 0) @@ -486,15 +592,20 @@ public async Task RepairAsync(RepairOptions opts, CancellationToke foreach (var line in lines) { var parts = line.Split('\t'); - if (parts.Length >= 2 && + if (parts.Length >= 3 && long.TryParse(parts[1], out var off) && - int.TryParse(parts.Length >= 3 ? parts[2] : "0", out var sz)) + int.TryParse(parts[2], out var sz)) { - var cvp = fileFragments.Values - .SelectMany(f => f) - .FirstOrDefault(f => f.CvpOffset == off)?.CvpFile; - if (cvp is not null) - corrupted.Add((cvp, off, sz)); + var allFragments = fileFragments.Values.SelectMany(f => f); + var fragment = parts.Length >= 4 + ? allFragments.FirstOrDefault(candidate => + string.Equals(Path.GetFullPath(candidate.CvpFile.FullName), Path.GetFullPath(parts[0]), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) && candidate.CvpOffset == off) + : allFragments.Where(candidate => candidate.CvpOffset == off) + .GroupBy(candidate => candidate.CvpFile.FullName).SingleOrDefault()?.FirstOrDefault(); + if (fragment is not null) corrupted.Add((fragment.CvpFile, off, sz)); } } } @@ -527,6 +638,18 @@ public async Task RepairAsync(RepairOptions opts, CancellationToke corrupted1: corrupted.Add((f.CvpFile, f.CvpOffset, f.BlockSize)); } + + foreach (var volume in opts.VolumeFiles) + { + var requiresVolumeHash = fileFragments.Values.SelectMany(fragments => fragments) + .Any(fragment => string.Equals(fragment.CvpFile.FullName, volume.FullName, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) && + (IntegrityLevel)(fragment.Flags >> 3 & 3) >= IntegrityLevel.Volume); + if (requiresVolumeHash && !VolumeIntegrityFooter.Verify(volume.FullName)) + corrupted.Add((volume, 0, 0)); + } } if (corrupted.Count == 0) @@ -557,19 +680,33 @@ public async Task RepairAsync(RepairOptions opts, CancellationToke File.Copy(srcPath, dstPath, true); } - await using var fs = new FileStream(dstPath, FileMode.Open, FileAccess.ReadWrite); - foreach (var (_, offset, size) in group) + await using (var fs = new FileStream(dstPath, FileMode.Open, FileAccess.ReadWrite)) { - var zeros = new byte[size]; - fs.Position = offset; - await fs.WriteAsync(zeros, token); - - var crc = Crc32.Compute(zeros.AsSpan(0, size - 4)); - var crcBytes = BitConverter.GetBytes(crc); - fs.Position = offset + size - 4; - await fs.WriteAsync(crcBytes, token); + foreach (var (_, offset, size) in group) + { + if (size == 0) continue; + var zeros = new byte[size]; + fs.Position = offset; + await fs.WriteAsync(zeros, token); + + var crc = Crc32.Compute(zeros.AsSpan(0, size - 4)); + var crcBytes = BitConverter.GetBytes(crc); + fs.Position = offset + size - 4; + await fs.WriteAsync(crcBytes, token); + } + + await fs.FlushAsync(token); } + var requiresVolumeHash = fileFragments.Values.SelectMany(fragments => fragments) + .Any(fragment => string.Equals(fragment.CvpFile.FullName, srcPath, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) && + (IntegrityLevel)(fragment.Flags >> 3 & 3) >= IntegrityLevel.Volume); + if (requiresVolumeHash) + VolumeIntegrityFooter.Rewrite(dstPath); + repairedVolumes.Add(dstPath); } @@ -611,12 +748,23 @@ internal static List FindIncompleteFiles(Dictionary f.IsFirst && - (FileEntryHeaderFlagsEnum)(f.Flags & 3) is FileEntryHeaderFlagsEnum.Full - or FileEntryHeaderFlagsEnum.CrossHead); - var hasCrossTail = fragments.Any(f => !f.IsFirst && - (FileEntryHeaderFlagsEnum)(f.Flags & 3) is FileEntryHeaderFlagsEnum.Full - or FileEntryHeaderFlagsEnum.CrossTail); + var volumeNumbers = fragments + .Select(fragment => TryGetVolumeNumber(fragment.CvpFile)) + .Where(number => number.HasValue) + .Select(number => number!.Value) + .Distinct() + .OrderBy(number => number) + .ToArray(); + if (volumeNumbers.Length > 1 && volumeNumbers[^1] - volumeNumbers[0] + 1 != volumeNumbers.Length) + { + incomplete.Add(path); + continue; + } + + var firstType = (FileEntryHeaderFlagsEnum)(fragments[0].Flags & 3); + var lastType = (FileEntryHeaderFlagsEnum)(fragments[^1].Flags & 3); + var hasCrossHead = firstType is FileEntryHeaderFlagsEnum.Full or FileEntryHeaderFlagsEnum.CrossHead; + var hasCrossTail = lastType is FileEntryHeaderFlagsEnum.Full or FileEntryHeaderFlagsEnum.CrossTail; if (!hasCrossHead || !hasCrossTail) incomplete.Add(path); } @@ -624,6 +772,16 @@ internal static List FindIncompleteFiles(Dictionary从标准 prefix.index.cvp 文件名中读取卷编号。 + /// 卷文件。 + /// 可识别的卷编号;不符合标准命名时返回 。 + private static int? TryGetVolumeNumber(FileInfo file) + { + var name = Path.GetFileNameWithoutExtension(file.Name); + var separator = name.LastIndexOf('.'); + return separator >= 0 && int.TryParse(name[(separator + 1)..], out var number) ? number : null; + } + private static GlobMatcher BuildGlobMatcher(string? include, string? exclude) { var m = new GlobMatcher(); @@ -631,4 +789,34 @@ private static GlobMatcher BuildGlobMatcher(string? include, string? exclude) if (!string.IsNullOrWhiteSpace(exclude)) m.AddExclude(exclude); return m; } -} + + /// 将扫描到的卷片段转换为验证引擎的输入元数据。 + /// 文件相对路径。 + /// 源卷片段。 + /// 文件内片段顺序。 + /// 可供 读取的块元数据。 + private static BlockMetadata CreateVerificationItem(string path, VolumeScanner.Fragment fragment, int sequence) + { + return new BlockMetadata + { + RelativePath = path, + SourceFullPath = fragment.CvpFile.FullName, + TargetIndex = 0, + Sequence = sequence, + SourceOffset = fragment.CvpOffset, + Length = fragment.BlockSize, + TotalFileSize = fragment.TotalFileSize, + Flags = fragment.Flags, + IsFirstFragment = fragment.StartsEntry + }; + } + + /// 以统一 Hook 观察方式运行已构造的数据引擎。 + /// 已锁定的数据处理引擎。 + /// 取消令牌。 + private static async Task RunPipelineAsync(ProcessingEngine pipeline, CancellationToken token) + { + using var observer = new ProcessingEngineObserver(pipeline); + await observer.RunAsync(token); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Helper/Models/PackOptions.cs b/CrypVol.Lib/Helper/Models/PackOptions.cs index 03cf113..fa25a22 100644 --- a/CrypVol.Lib/Helper/Models/PackOptions.cs +++ b/CrypVol.Lib/Helper/Models/PackOptions.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; using CrypVol.Lib.Crypto; +using CrypVol.Lib.Volume; namespace CrypVol.Lib.Helper.Models; @@ -13,14 +15,6 @@ public sealed record PackOptions /// 输出目录 public required DirectoryInfo OutputDir { get; init; } - /// .cvk 单独输出目录(null = 与 OutputDir 相同) - [field: AllowNull] - public DirectoryInfo KeyOutputDir - { - get => field ?? OutputDir; - init; - } - /// 文件名前缀 [field: AllowNull] public string OutputPrefix @@ -29,7 +23,7 @@ public string OutputPrefix init; } - /// 单卷大小上限(MiB) + /// 单卷近似切分目标(MiB);处理后的块可能因压缩、加密或完整性数据略有偏差。 public uint VolumeSizeMb { get; init; } = 1024; /// @@ -41,14 +35,14 @@ public string OutputPrefix /// 已加载的 CEK 凭据(CLI 预加载后传入) public required CvkCredentials Credentials { get; init; } - /// 启用 GZip 压缩 - public bool EnableCompression { get; init; } + /// 启用 GZip 压缩。 + public bool EnableCompression { get; init; } = true; - /// 压缩级别 0-9 - public int CompressionLevel { get; init; } = 6; + /// .NET GZip 压缩预设。 + public CompressionLevel CompressionLevel { get; init; } = CompressionLevel.Optimal; - /// 完整性校验级别 - public IntegrityLevel IntegrityLevel { get; init; } = IntegrityLevel.File; + /// 完整性校验级别。 + public IntegrityLevel IntegrityLevel { get; init; } = IntegrityLevel.Block; } public sealed record PackResult @@ -58,4 +52,4 @@ public sealed record PackResult public IReadOnlyList VolumePaths { get; init; } = []; public int VolumeCount { get; init; } public long TotalBytes { get; init; } -} +} \ No newline at end of file diff --git a/CrypVol.Lib/Helper/Models/VerifyOptions.cs b/CrypVol.Lib/Helper/Models/VerifyOptions.cs index d4dbc61..f71a7a7 100644 --- a/CrypVol.Lib/Helper/Models/VerifyOptions.cs +++ b/CrypVol.Lib/Helper/Models/VerifyOptions.cs @@ -33,6 +33,9 @@ public sealed record VerifyResult public sealed record CorruptedBlock { + /// 损坏数据块所在 CVP 卷的完整路径。 + public required string VolumePath { get; init; } + public required string FilePath { get; init; } public long CvpOffset { get; init; } public int BlockSize { get; init; } diff --git a/CrypVol.Lib/BrowseOutputMode.cs b/CrypVol.Lib/Volume/BrowseOutputMode.cs similarity index 94% rename from CrypVol.Lib/BrowseOutputMode.cs rename to CrypVol.Lib/Volume/BrowseOutputMode.cs index 5066894..ff7b535 100644 --- a/CrypVol.Lib/BrowseOutputMode.cs +++ b/CrypVol.Lib/Volume/BrowseOutputMode.cs @@ -1,4 +1,4 @@ -namespace CrypVol.Lib; +namespace CrypVol.Lib.Volume; /// 浏览输出格式 public enum BrowseOutputMode diff --git a/CrypVol.Lib/IntegrityLevel.cs b/CrypVol.Lib/Volume/IntegrityLevel.cs similarity index 93% rename from CrypVol.Lib/IntegrityLevel.cs rename to CrypVol.Lib/Volume/IntegrityLevel.cs index c0f0cec..eb90d08 100644 --- a/CrypVol.Lib/IntegrityLevel.cs +++ b/CrypVol.Lib/Volume/IntegrityLevel.cs @@ -1,4 +1,4 @@ -namespace CrypVol.Lib; +namespace CrypVol.Lib.Volume; /// /// 数据完整性校验级别 diff --git a/CrypVol.Lib/Volume/VolumeIntegrityFooter.cs b/CrypVol.Lib/Volume/VolumeIntegrityFooter.cs new file mode 100644 index 0000000..bda13ae --- /dev/null +++ b/CrypVol.Lib/Volume/VolumeIntegrityFooter.cs @@ -0,0 +1,92 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CrypVol.Lib.Volume; + +/// 保存并验证 Volume 级 SHA-256 的固定长度卷尾记录。 +public static class VolumeIntegrityFooter +{ + /// 卷尾魔数的长度。 + public const int MagicLength = 4; + + /// SHA-256 摘要的长度。 + public const int HashLength = 32; + + /// 卷尾记录总长度。 + public const int Length = MagicLength + HashLength; + + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("CVPF"); + + /// 构建卷尾记录。 + /// 卷正文的 SHA-256 摘要。 + /// 可直接追加到卷末的记录。 + public static byte[] Create(byte[] hash) + { + ArgumentNullException.ThrowIfNull(hash); + if (hash.Length != HashLength) throw new ArgumentException("卷 SHA-256 摘要长度无效。", nameof(hash)); + var footer = new byte[Length]; + Magic.CopyTo(footer, 0); + hash.CopyTo(footer, MagicLength); + return footer; + } + + /// 判断字节序列是否以卷尾魔数开头。 + /// 待检查的字节序列。 + /// 是否为卷尾魔数。 + public static bool IsMagic(ReadOnlySpan value) + { + return value.Length >= MagicLength && value[..MagicLength].SequenceEqual(Magic); + } + + /// 验证卷尾记录和卷正文 SHA-256。 + /// 卷文件路径。 + /// 卷尾有效时返回 true。 + public static bool Verify(string path) + { + using var stream = File.OpenRead(path); + if (stream.Length < Length) return false; + stream.Position = stream.Length - Length; + var footer = new byte[Length]; + if (stream.Read(footer) != footer.Length || !footer.AsSpan(0, MagicLength).SequenceEqual(Magic)) return false; + stream.Position = 0; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[1024 * 1024]; + var remaining = stream.Length - Length; + while (remaining > 0) + { + var read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); + if (read == 0) return false; + hash.AppendData(buffer, 0, read); + remaining -= read; + } + + return CryptographicOperations.FixedTimeEquals(footer.AsSpan(MagicLength), hash.GetHashAndReset()); + } + + /// 使用当前卷正文重写卷尾记录。 + /// 卷文件路径。 + public static void Rewrite(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + if (stream.Length < Length) throw new InvalidDataException("卷缺少完整性卷尾。"); + stream.Position = stream.Length - Length; + var magic = new byte[MagicLength]; + if (stream.Read(magic) != magic.Length || !magic.AsSpan().SequenceEqual(Magic)) + throw new InvalidDataException("卷缺少完整性卷尾。"); + stream.Position = 0; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[1024 * 1024]; + var remaining = stream.Length - Length; + while (remaining > 0) + { + var read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); + if (read == 0) throw new EndOfStreamException(); + hash.AppendData(buffer, 0, read); + remaining -= read; + } + + var footer = Create(hash.GetHashAndReset()); + stream.Position = stream.Length - Length; + stream.Write(footer); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Volume/VolumePathSafety.cs b/CrypVol.Lib/Volume/VolumePathSafety.cs new file mode 100644 index 0000000..e479c83 --- /dev/null +++ b/CrypVol.Lib/Volume/VolumePathSafety.cs @@ -0,0 +1,46 @@ +namespace CrypVol.Lib.Volume; + +/// 验证归档路径并将输出路径限制在指定根目录内。 +public static class VolumePathSafety +{ + /// 验证归档中使用的相对文件路径。 + /// 来自归档头或调用方的相对路径。 + /// 路径不是安全的相对文件路径时抛出。 + public static void ValidateRelativePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + throw new InvalidDataException("归档条目路径不能为空。"); + if (Path.IsPathRooted(relativePath) || IsWindowsRootedPath(relativePath)) + throw new InvalidDataException($"归档条目路径不能是绝对路径: {relativePath}"); + + var segments = relativePath.Replace('\\', '/').Split('/'); + if (segments.Any(segment => string.IsNullOrEmpty(segment) || segment is "." or "..")) + throw new InvalidDataException($"归档条目路径包含不安全的目录段: {relativePath}"); + } + + /// 解析输出路径,并确认其仍位于指定输出根目录中。 + /// 允许写入的输出根目录。 + /// 待解析的绝对或相对候选路径。 + /// 经过规范化且位于输出根目录内的完整路径。 + /// 候选路径越出输出根目录时抛出。 + public static string ResolveUnderRoot(string outputRoot, string candidatePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(candidatePath); + + var root = Path.GetFullPath(outputRoot); + var fullPath = Path.GetFullPath(Path.IsPathFullyQualified(candidatePath) + ? candidatePath + : Path.Combine(root, candidatePath)); + var relativePath = Path.GetRelativePath(root, fullPath); + ValidateRelativePath(relativePath); + return fullPath; + } + + private static bool IsWindowsRootedPath(string path) + { + return path.StartsWith("\\\\", StringComparison.Ordinal) || + path.Length >= 3 && char.IsAsciiLetter(path[0]) && path[1] == ':' && + (path[2] == '/' || path[2] == '\\'); + } +} \ No newline at end of file diff --git a/CrypVol.Lib/Volume/VolumeScanner.cs b/CrypVol.Lib/Volume/VolumeScanner.cs index d55d280..af74554 100644 --- a/CrypVol.Lib/Volume/VolumeScanner.cs +++ b/CrypVol.Lib/Volume/VolumeScanner.cs @@ -8,10 +8,11 @@ namespace CrypVol.Lib.Volume; /// 扫描 .cvp 卷头,构建文件→片段映射。魔数自动识别明文(CVPH)/加密(CVPE)头。filter 非 null 时跳过不匹配的文件。 public static class VolumeScanner { - public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, + public static ScanResult Scan(IReadOnlyCollection volumes, byte[]? cek = null, GlobMatcher? filter = null, ILogger? logger = null) { var files = new Dictionary>(); + var orderedBlocks = new List(); var possiblyEncrypted = false; Span magicBuf = stackalloc byte[4]; @@ -31,6 +32,7 @@ public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, if (fs.Read(magicBuf) < 4) break; var magic = BitConverter.ToUInt32(magicBuf); + if (VolumeIntegrityFooter.IsMagic(magicBuf)) break; if (!FileEntryHeader.IsValidMagic(magic)) break; @@ -69,6 +71,8 @@ public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, try { relPath = ReadPath(hdr); } catch { break; } + VolumePathSafety.ValidateRelativePath(relPath); + pos += headerLen; var includeFile = filter is null || filter.IsMatch(relPath); @@ -79,17 +83,15 @@ public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, fs.Position = pos; if (fs.Read(peekBuf) < 4) break; if (FileEntryHeader.IsValidMagic(BitConverter.ToUInt32(peekBuf))) break; - var skipLen = BitConverter.ToInt32(peekBuf); - if (skipLen < 0) break; - pos += 4 + skipLen; + var skipLen = ReadBlockLength(peekBuf, fs.Length, pos, cvp); + pos += sizeof(int) + (long)skipLen; } continue; } var flags = (FileEntryHeaderFlagsEnum)hdr.Flags; - var isFirst = !flags.HasFlag(FileEntryHeaderFlagsEnum.CrossMid) - && !flags.HasFlag(FileEntryHeaderFlagsEnum.CrossTail); + var startsEntry = true; while (pos + 4 <= fs.Length) { @@ -97,17 +99,25 @@ public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, if (fs.Read(peekBuf) < 4) break; var nextMagic = BitConverter.ToUInt32(peekBuf); + if (VolumeIntegrityFooter.IsMagic(peekBuf)) + { + pos = fs.Length; + break; + } + if (FileEntryHeader.IsValidMagic(nextMagic)) break; - var blockLen = BitConverter.ToInt32(peekBuf); - if (blockLen < 0) break; + var blockLen = ReadBlockLength(peekBuf, fs.Length, pos, cvp); pos += 4; if (!files.TryGetValue(relPath, out var list)) files[relPath] = list = []; - list.Add(new Fragment(cvp, pos, blockLen, hdr.SizeOrTotal, - hdr.Flags, isFirst && list.Count == 0, hdr.FragmentIndex)); + var fragment = new Fragment(cvp, pos, blockLen, hdr.SizeOrTotal, + hdr.Flags, startsEntry, hdr.FragmentIndex); + list.Add(fragment); + orderedBlocks.Add(new ScannedBlock(relPath, fragment)); + startsEntry = false; pos += blockLen; } @@ -119,6 +129,7 @@ public static ScanResult Scan(IEnumerable volumes, byte[]? cek = null, return new ScanResult { Files = files, + OrderedBlocks = orderedBlocks, PossiblyEncrypted = possiblyEncrypted }; } @@ -132,17 +143,43 @@ private static string ReadPath(FileEntryHeader hdr) return Encoding.UTF8.GetString(bytes, start, end - start); } + /// 读取并验证当前位置的块长度,确保块完整位于当前卷文件中。 + /// 长度字段的四个字节。 + /// 当前卷文件长度。 + /// 长度字段在卷中的偏移。 + /// 当前卷文件。 + /// 已验证的块长度。 + private static int ReadBlockLength(ReadOnlySpan lengthBytes, long fileLength, long lengthOffset, + FileInfo cvp) + { + var length = BitConverter.ToInt32(lengthBytes); + var remaining = fileLength - lengthOffset - sizeof(int); + if (length < 0 || length > remaining) + throw new InvalidDataException( + $"CVP 数据块长度无效: {cvp.FullName} offset=0x{lengthOffset:X}, length={length}"); + return length; + } + public record Fragment(FileInfo CvpFile, long CvpOffset, int BlockSize, long TotalFileSize, byte Flags, - bool IsFirst, + bool StartsEntry, uint FragmentIndex); + /// 保留卷内物理顺序的已扫描数据块。 + /// 所属文件的相对路径。 + /// 数据块及其来源信息。 + public sealed record ScannedBlock(string RelativePath, Fragment Fragment); + public sealed class ScanResult { public required Dictionary> Files { get; init; } + + /// 按输入卷与卷内出现顺序排列的数据块,适用于格式保真的重写流程。 + public required IReadOnlyList OrderedBlocks { get; init; } + public bool PossiblyEncrypted { get; init; } } } \ No newline at end of file diff --git a/CrypVol.Tests/CrypVolEngineTests.cs b/CrypVol.Tests/CrypVolEngineTests.cs index a7a9cb9..663f2b1 100644 --- a/CrypVol.Tests/CrypVolEngineTests.cs +++ b/CrypVol.Tests/CrypVolEngineTests.cs @@ -1,9 +1,11 @@ +using System.IO.Compression; using System.Security.Cryptography; using System.Text; -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Helper; using CrypVol.Lib.Helper.Models; +using CrypVol.Lib.Utility; +using CrypVol.Lib.Volume; using Xunit; namespace CrypVol.Tests; @@ -74,6 +76,8 @@ public async Task PackExtract_NoneMode_SingleFile_RoundTrip() Assert.True(packResult.Success, packResult.Error); Assert.Single(packResult.VolumePaths); Assert.True(File.Exists(packResult.VolumePaths[0])); + var volumeBytes = await File.ReadAllBytesAsync(packResult.VolumePaths[0]); + Assert.NotEqual(0, volumeBytes[12] & (byte)FileEntryHeaderFlagsEnum.Compressed); var extractOpts = new ExtractOptions { @@ -360,6 +364,61 @@ public async Task Convert_PlainKeyToPlainKey_RoundTrip() Assert.Equal(data, await File.ReadAllBytesAsync(Path.Combine(restoreDir.FullName, "data.bin"))); } + /// 转换必须保留多块、跨卷文件的条目边界和物理写入顺序。 + [Fact] + public async Task Convert_MultiBlockCrossVolumeFile_RoundTrip() + { + var data = RandomNumberGenerator.GetBytes(4_500_000); + MakeFile("large.bin", data); + var packedDirectory = _workDir.CreateSubdirectory("packed"); + var convertedDirectory = _workDir.CreateSubdirectory("converted"); + var restoredDirectory = _workDir.CreateSubdirectory("restored"); + var engine = new CrypVolHelper(); + var oldCredentials = new CvkCredentials(EncryptionMode.PlainKey, RandomNumberGenerator.GetBytes(32)); + var newCredentials = new CvkCredentials(EncryptionMode.PlainKey, RandomNumberGenerator.GetBytes(32)); + + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "large.bin"))], + OutputDir = packedDirectory, + Credentials = oldCredentials, + EnableCompression = false, + IntegrityLevel = IntegrityLevel.File, + ChunkSizeMb = 1, + VolumeSizeMb = 3 + }); + Assert.True(packResult.Success, packResult.Error); + Assert.True(packResult.VolumeCount > 1); + + var convertResult = await engine.ConvertAsync(new ConvertOptions + { + VolumeFiles = CvpFiles(packResult), + OutputDir = convertedDirectory, + OutputPrefix = "converted", + OldCredentials = oldCredentials, + NewCredentials = newCredentials + }); + Assert.True(convertResult.Success, convertResult.Error); + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = convertResult.VolumePaths.Select(path => new FileInfo(path)).ToList(), + Credentials = newCredentials + }); + Assert.True(verifyResult.Success, verifyResult.Error); + Assert.Empty(verifyResult.CorruptedEntries); + + var extractResult = await engine.ExtractAsync(new ExtractOptions + { + VolumeFiles = convertResult.VolumePaths.Select(path => new FileInfo(path)).ToList(), + OutputDir = restoredDirectory, + Credentials = newCredentials + }); + Assert.True(extractResult.Success, extractResult.Error); + Assert.Equal(data, await File.ReadAllBytesAsync(Path.Combine(restoredDirectory.FullName, "large.bin"))); + } + // ═══════════════════════════════════════════════════════ // Pack → Extract with include/exclude glob // ═══════════════════════════════════════════════════════ @@ -468,12 +527,14 @@ public async Task Pack_MultiVolume_LargeFile_SplitsAcrossVolumes() OutputDir = outDir, Credentials = credentials, VolumeSizeMb = 3, - ChunkSizeMb = 1 + ChunkSizeMb = 1, + IntegrityLevel = IntegrityLevel.Volume }; var packResult = await engine.PackAsync(packOpts); Assert.True(packResult.Success, packResult.Error); Assert.True(packResult.VolumeCount >= 3); + Assert.All(packResult.VolumePaths, path => Assert.True(VolumeIntegrityFooter.Verify(path))); var extractOpts = new ExtractOptions { @@ -490,6 +551,314 @@ public async Task Pack_MultiVolume_LargeFile_SplitsAcrossVolumes() Assert.Equal(data, await File.ReadAllBytesAsync(restored)); } + /// File 级摘要应发现 CRC 被重新计算后的静默数据篡改。 + [Fact] + public async Task Extract_FileIntegrity_DetectsPayloadTamperingWithValidCrc() + { + var data = RandomNumberGenerator.GetBytes(1_500_000); + MakeFile("protected.bin", data); + var outDir = _workDir.CreateSubdirectory("out"); + var restoreDir = _workDir.CreateSubdirectory("restore"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "protected.bin"))], + OutputDir = outDir, + Credentials = credentials, + ChunkSizeMb = 1, + EnableCompression = false, + IntegrityLevel = IntegrityLevel.File + }); + Assert.True(packResult.Success, packResult.Error); + + var fragment = VolumeScanner.Scan(CvpFiles(packResult)).Files["protected.bin"][0]; + RewriteBlockWithValidCrc(fragment, buffer => buffer[0] ^= 0xFF); + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = CvpFiles(packResult), + Credentials = credentials + }); + Assert.True(verifyResult.Success, verifyResult.Error); + Assert.Contains(verifyResult.CorruptedEntries, entry => entry.FilePath == "protected.bin"); + + var extractResult = await engine.ExtractAsync(new ExtractOptions + { + VolumeFiles = CvpFiles(packResult), + OutputDir = restoreDir, + Credentials = credentials + }); + + Assert.False(extractResult.Success); + Assert.Contains("SHA-256", extractResult.Error); + } + + /// 验证引擎应在同一轮运行中汇总多个 File 摘要失败,而非遇到首个失败即停止。 + [Fact] + public async Task Verify_FileIntegrity_ReportsAllTamperedFiles() + { + MakeFile("first.bin", RandomNumberGenerator.GetBytes(8_192)); + MakeFile("second.bin", RandomNumberGenerator.GetBytes(8_192)); + var outDir = _workDir.CreateSubdirectory("out"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = + [ + new FileInfo(Path.Combine(_workDir.FullName, "first.bin")), + new FileInfo(Path.Combine(_workDir.FullName, "second.bin")) + ], + OutputDir = outDir, + Credentials = credentials, + EnableCompression = false, + IntegrityLevel = IntegrityLevel.File + }); + Assert.True(packResult.Success, packResult.Error); + + var fragments = VolumeScanner.Scan(CvpFiles(packResult)).Files; + RewriteBlockWithValidCrc(fragments["first.bin"][0], buffer => buffer[0] ^= 0xFF); + RewriteBlockWithValidCrc(fragments["second.bin"][0], buffer => buffer[0] ^= 0xFF); + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = CvpFiles(packResult), + Credentials = credentials + }); + + Assert.True(verifyResult.Success, verifyResult.Error); + Assert.Equal(2, verifyResult.CorruptedFiles); + Assert.Equal(["first.bin", "second.bin"], + verifyResult.CorruptedEntries.Select(entry => entry.FilePath).OrderBy(path => path)); + } + + /// 混合不同数据格式的卷不能被误当成同一归档并拆分为多条验证流水线。 + [Fact] + public async Task Verify_MixedFormatVolumes_ReturnsConfigurationError() + { + MakeFile("block.bin", RandomNumberGenerator.GetBytes(1_024)); + MakeFile("file.bin", RandomNumberGenerator.GetBytes(1_024)); + var blockOutDir = _workDir.CreateSubdirectory("block-out"); + var fileOutDir = _workDir.CreateSubdirectory("file-out"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + + var blockPack = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "block.bin"))], + OutputDir = blockOutDir, + Credentials = credentials, + IntegrityLevel = IntegrityLevel.Block + }); + var filePack = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "file.bin"))], + OutputDir = fileOutDir, + Credentials = credentials, + IntegrityLevel = IntegrityLevel.File + }); + Assert.True(blockPack.Success, blockPack.Error); + Assert.True(filePack.Success, filePack.Error); + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = [.. CvpFiles(blockPack), .. CvpFiles(filePack)], + Credentials = credentials + }); + + Assert.False(verifyResult.Success); + Assert.Contains("配置不一致", verifyResult.Error); + } + + /// Block 级 CRC 损坏应被定位;修复副本应重新通过 CRC 校验。 + [Fact] + public async Task VerifyAndRepair_BlockIntegrity_DetectsAndRepairsCorruption() + { + var data = RandomNumberGenerator.GetBytes(16_384); + MakeFile("block.bin", data); + var outDir = _workDir.CreateSubdirectory("out"); + var repairedDir = _workDir.CreateSubdirectory("repaired"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "block.bin"))], + OutputDir = outDir, + Credentials = credentials, + IntegrityLevel = IntegrityLevel.Block + }); + Assert.True(packResult.Success, packResult.Error); + + var fragment = VolumeScanner.Scan(CvpFiles(packResult)).Files["block.bin"][0]; + RewriteBlockWithValidCrc(fragment, buffer => buffer[0] ^= 0xFF, false); + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = CvpFiles(packResult), + Credentials = credentials + }); + Assert.True(verifyResult.Success, verifyResult.Error); + var corruption = Assert.Single(verifyResult.CorruptedEntries); + Assert.Equal(fragment.CvpFile.FullName, corruption.VolumePath); + Assert.Equal(fragment.CvpOffset, corruption.CvpOffset); + + var repairResult = await engine.RepairAsync(new RepairOptions + { + VolumeFiles = CvpFiles(packResult), + Credentials = credentials, + OutputDir = repairedDir + }); + Assert.True(repairResult.Success, repairResult.Error); + Assert.Equal(1, repairResult.RepairedBlocks); + + var repairedVerify = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = repairResult.RepairedVolumes.Select(path => new FileInfo(path)).ToList(), + Credentials = credentials + }); + Assert.True(repairedVerify.Success, repairedVerify.Error); + Assert.Empty(repairedVerify.CorruptedEntries); + } + + /// Volume 卷尾摘要损坏后,校验和修复都应针对该卷生效。 + [Fact] + public async Task VerifyAndRepair_VolumeIntegrity_DetectsAndRewritesFooter() + { + MakeFile("volume.bin", RandomNumberGenerator.GetBytes(16_384)); + var outDir = _workDir.CreateSubdirectory("out"); + var repairedDir = _workDir.CreateSubdirectory("repaired"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "volume.bin"))], + OutputDir = outDir, + Credentials = credentials, + IntegrityLevel = IntegrityLevel.Volume + }); + Assert.True(packResult.Success, packResult.Error); + var volume = CvpFiles(packResult).Single(); + await using (var stream = new FileStream(volume.FullName, FileMode.Open, FileAccess.ReadWrite)) + { + stream.Position = stream.Length - 1; + stream.WriteByte(0); + } + + var verifyResult = await engine.VerifyAsync(new VerifyOptions + { + VolumeFiles = [volume], + Credentials = credentials + }); + Assert.True(verifyResult.Success, verifyResult.Error); + Assert.Contains(verifyResult.CorruptedEntries, entry => entry.BlockSize == 0); + + var repairResult = await engine.RepairAsync(new RepairOptions + { + VolumeFiles = [volume], + Credentials = credentials, + OutputDir = repairedDir + }); + Assert.True(repairResult.Success, repairResult.Error); + var repairedVolume = Assert.Single(repairResult.RepairedVolumes); + Assert.True(VolumeIntegrityFooter.Verify(repairedVolume)); + } + + /// 压缩、加密、多卷和 Volume 完整性组合后仍应完整还原。 + [Fact] + public async Task PackExtract_CompressedEncryptedMultiVolume_RoundTrip() + { + var data = RandomNumberGenerator.GetBytes(3_500_000); + MakeFile("combined.bin", data); + var outDir = _workDir.CreateSubdirectory("out"); + var restoreDir = _workDir.CreateSubdirectory("restore"); + var credentials = new CvkCredentials(EncryptionMode.PlainKey, RandomNumberGenerator.GetBytes(32)); + var engine = new CrypVolHelper(); + + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "combined.bin"))], + OutputDir = outDir, + Credentials = credentials, + EnableCompression = true, + CompressionLevel = CompressionLevel.SmallestSize, + VolumeSizeMb = 2, + ChunkSizeMb = 1, + IntegrityLevel = IntegrityLevel.Volume + }); + Assert.True(packResult.Success, packResult.Error); + Assert.True(packResult.VolumeCount >= 2); + + var extractResult = await engine.ExtractAsync(new ExtractOptions + { + VolumeFiles = CvpFiles(packResult), + OutputDir = restoreDir, + Credentials = credentials + }); + Assert.True(extractResult.Success, extractResult.Error); + Assert.Equal(data, await File.ReadAllBytesAsync(Path.Combine(restoreDir.FullName, "combined.bin"))); + } + + /// 缺失中间分卷时,不应输出看似完整的文件。 + [Fact] + public async Task Extract_MissingIntermediateVolume_FailsWithoutRestoringPartialFile() + { + var data = RandomNumberGenerator.GetBytes(5_000_000); + MakeFile("split.bin", data); + var outDir = _workDir.CreateSubdirectory("out"); + var restoreDir = _workDir.CreateSubdirectory("restore"); + var credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()); + var engine = new CrypVolHelper(); + var packResult = await engine.PackAsync(new PackOptions + { + SourceFolder = _workDir, + SourceFiles = [new FileInfo(Path.Combine(_workDir.FullName, "split.bin"))], + OutputDir = outDir, + Credentials = credentials, + VolumeSizeMb = 2, + ChunkSizeMb = 1 + }); + Assert.True(packResult.Success, packResult.Error); + Assert.True(packResult.VolumeCount >= 3); + + var incompleteVolumes = CvpFiles(packResult) + .Where((_, index) => index != 1) + .ToList(); + var extractResult = await engine.ExtractAsync(new ExtractOptions + { + VolumeFiles = incompleteVolumes, + OutputDir = restoreDir, + Credentials = credentials + }); + + Assert.False(extractResult.Success); + Assert.False(File.Exists(Path.Combine(restoreDir.FullName, "split.bin"))); + } + + /// 辅助地篡改单个存储块,可选择同步更新 CRC。 + private static void RewriteBlockWithValidCrc(VolumeScanner.Fragment fragment, + Action mutate, bool recomputeCrc = true) + { + var block = new byte[fragment.BlockSize]; + using var stream = new FileStream(fragment.CvpFile.FullName, FileMode.Open, FileAccess.ReadWrite); + stream.Position = fragment.CvpOffset; + Assert.Equal(block.Length, stream.Read(block)); + mutate(block); + if (recomputeCrc) + BitConverter.GetBytes(Crc32.Compute(block.AsSpan(0, block.Length - sizeof(uint)))) + .CopyTo(block, block.Length - sizeof(uint)); + stream.Position = fragment.CvpOffset; + stream.Write(block); + } + // ═══════════════════════════════════════════════════════ // Pack: binary data roundtrip // ═══════════════════════════════════════════════════════ @@ -628,4 +997,4 @@ public async Task Browse_EmptyVolumeFiles_ReturnsEmptyList() Assert.True(result.Success); Assert.Empty(result.Files); } -} +} \ No newline at end of file diff --git a/CrypVol.Tests/CvkDocumentTests.cs b/CrypVol.Tests/CvkDocumentTests.cs index 3b64c07..84c1068 100644 --- a/CrypVol.Tests/CvkDocumentTests.cs +++ b/CrypVol.Tests/CvkDocumentTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; -using CrypVol.Lib; using CrypVol.Lib.Crypto; +using Org.BouncyCastle.Crypto.Utilities; +using Org.BouncyCastle.Security; using Xunit; namespace CrypVol.Tests; @@ -39,7 +40,7 @@ public async Task ChangePasswordAndPayload_BuildsLoadableDocument() document.Password = "new-password"; await document.WriteAsync(file); - var loaded = await CvkLoader.LoadAsync(file, password: "new-password"); + var loaded = await CvkLoader.LoadAsync(file, "new-password"); Assert.Equal(document.Cek, loaded.Cek); Assert.Equal(EncryptionMode.Password, loaded.EncryptionMode); @@ -66,6 +67,31 @@ public void PublicKeyRecipients_CanBeAddedAndRemoved() Assert.Empty(document.NewPublicKeyFiles); } + [Fact] + public async Task SshPrivateKeyDiscovery_OnlyReturnsPairedPrivateKeys() + { + var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"cvk-ssh-{Guid.NewGuid()}")); + directory.Create(); + try + { + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "id_rsa"), "private"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "id_rsa.pub"), "public"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "unpaired"), "private"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "known_hosts"), "host"); + await File.WriteAllTextAsync(Path.Combine(directory.FullName, "config"), "config"); + + var candidates = SshPrivateKeyDiscovery.DiscoverPrivateKeys(directory).ToArray(); + + var candidate = Assert.Single(candidates); + Assert.Equal("id_rsa", candidate.Name); + } + finally + { + try { directory.Delete(true); } + catch { } + } + } + [Fact] public async Task LoadedPublicKeyRecipients_ArePreservedWithoutTheirPemFiles() { @@ -113,12 +139,51 @@ public async Task LoadedPublicKeyRecipients_ArePreservedWithoutTheirPemFiles() } } - private static FileInfo CreateFile() => - new(Path.Combine(Path.GetTempPath(), $"cvk-document-{Guid.NewGuid()}.cvk")); + [Fact] + public async Task OpenSshRsaKeyPair_CanCreateAndLoadAsymmetricDocument() + { + var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"cvk-openssh-{Guid.NewGuid()}")); + directory.Create(); + try + { + var publicKey = new FileInfo(Path.Combine(directory.FullName, "id_rsa.pub")); + var privateKey = new FileInfo(Path.Combine(directory.FullName, "id_rsa")); + using (var rsa = RSA.Create(2048)) + { + var pair = DotNetUtilities.GetRsaKeyPair(rsa); + var publicBlob = OpenSshPublicKeyUtilities.EncodePublicKey(pair.Public); + var privateBlob = OpenSshPrivateKeyUtilities.EncodePrivateKey(pair.Private); + await File.WriteAllTextAsync(publicKey.FullName, + $"ssh-rsa {Convert.ToBase64String(publicBlob)} alice@example.com"); + await File.WriteAllTextAsync(privateKey.FullName, + $"-----BEGIN OPENSSH PRIVATE KEY-----\n{Convert.ToBase64String(privateBlob)}\n-----END OPENSSH PRIVATE KEY-----\n"); + } + + var original = CvkDocument.CreateNew(EncryptionMode.Asymmetric); + original.AddPublicKey(publicKey); + var file = new FileInfo(Path.Combine(directory.FullName, "key.cvk")); + await original.WriteAsync(file); + + var loaded = await CvkLoader.LoadAsync(file, privateKeyFile: privateKey); + + Assert.Equal(original.Cek, loaded.Cek); + Assert.Equal("alice@example.com", Assert.Single(loaded.PublicKeyRecipients).KeyId); + } + finally + { + try { directory.Delete(true); } + catch { } + } + } + + private static FileInfo CreateFile() + { + return new FileInfo(Path.Combine(Path.GetTempPath(), $"cvk-document-{Guid.NewGuid()}.cvk")); + } private static void Delete(FileInfo file) { try { file.Delete(); } catch { } } -} +} \ No newline at end of file diff --git a/CrypVol.Tests/DataPipelineTests.cs b/CrypVol.Tests/DataPipelineTests.cs index 8b30d64..06466cf 100644 --- a/CrypVol.Tests/DataPipelineTests.cs +++ b/CrypVol.Tests/DataPipelineTests.cs @@ -20,24 +20,40 @@ public async Task StartAsync_ConnectsOrderedProcessorStages() var firstProcessor = new AppendProcessor(3); var secondProcessor = new AppendProcessor(4); var receiver = new CollectingReceiver(); + var completedRecord = 0; var engine = ProcessingEngine.Builder() .UseProvider(provider) .AddProcessor(firstProcessor) .AddProcessor(secondProcessor) .UseReceiver(receiver) .Build(); + engine.Completed += (sender, _) => + { + var completedEngine = Assert.IsType(sender); + Assert.True(completedEngine.TryGetRecord("processed-blocks", out completedRecord)); + }; await engine.StartAsync(); var received = Assert.Single(receiver.Blocks); Assert.Same(metadata, received.Metadata); - Assert.Equal(new byte[] { 1, 2, 3, 4 }, received.Data.ToArray()); - Assert.Equal(new[] { "initialize", "validate", "prepare", "start", "process", "dispose" }, + Assert.Equal(new byte[] + { + 1, 2, 3, 4 + }, received.Data.ToArray()); + Assert.Equal(new[] + { + "initialize", "validate", "prepare", "start", "process", "dispose" + }, firstProcessor.LifeCycle); - Assert.Equal(new[] { "initialize", "validate", "prepare", "start", "process", "dispose" }, + Assert.Equal(new[] + { + "initialize", "validate", "prepare", "start", "process", "dispose" + }, secondProcessor.LifeCycle); Assert.True(engine.TryGetRecord("processed-blocks", out var processedBlocks)); Assert.Equal(2, processedBlocks); + Assert.Equal(2, completedRecord); received.Dispose(); } @@ -57,7 +73,49 @@ public async Task StartAsync_RaisesEngineEvents() await engine.StartAsync(); - Assert.Equal(new[] { "starting", "started", "completed", "stopped" }, events); + Assert.Equal(new[] + { + "starting", "started", "completed", "stopped" + }, events); + } + + /// 启动阶段必须先让下游就绪,再启动上游生产阶段。 + [Fact] + public async Task StartAsync_StartsStagesFromReceiverToProvider() + { + var order = new List(); + var engine = ProcessingEngine.Builder() + .UseProvider(new StartOrderProvider(order)) + .AddProcessor(new StartOrderProcessor(order, "processor-1")) + .AddProcessor(new StartOrderProcessor(order, "processor-2")) + .UseReceiver(new StartOrderReceiver(order)) + .Build(); + + await engine.StartAsync(); + + Assert.Equal(["receiver", "processor-2", "processor-1", "provider"], order); + } + + /// 观测事件处理器失败不得阻断其他观察器、完成状态或资源释放。 + [Fact] + public async Task StartAsync_ObserverHookFailure_DoesNotBreakPipeline() + { + var events = new List(); + var engine = ProcessingEngine.Builder() + .UseProvider(new SingleBlockProvider(CreateMetadata(), 1)) + .UseReceiver(new NullDataReceiver()) + .Build(); + engine.Started += (_, _) => throw new InvalidOperationException("started hook"); + engine.Started += (_, _) => events.Add("started"); + engine.Completed += (_, _) => throw new InvalidOperationException("completed hook"); + engine.Completed += (_, _) => events.Add("completed"); + engine.Stopped += (_, _) => throw new InvalidOperationException("stopped hook"); + engine.Stopped += (_, _) => events.Add("stopped"); + + await engine.StartAsync(); + + Assert.Equal(ProcessingEngineState.Completed, engine.State); + Assert.Equal(["started", "completed", "stopped"], events); } /// 构造完成后 EngineBuilder 应锁定。 @@ -104,6 +162,127 @@ public async Task StartAsync_ProcessorFailure_InterruptsAllStages() Assert.True(receiver.Disposed); } + /// 提供阶段失败时,接收阶段应被取消,且失败与停止事件各只触发一次。 + [Fact] + public async Task StartAsync_ProviderFailure_InterruptsReceiverAndRaisesFailureEvents() + { + var provider = new FailingProvider(); + var receiver = new CancellationObservingReceiver(); + var events = new List(); + var engine = ProcessingEngine.Builder() + .UseProvider(provider) + .UseReceiver(receiver) + .Build(); + engine.Failed += (_, _) => events.Add("failed"); + engine.Stopped += (_, _) => events.Add("stopped"); + + await Assert.ThrowsAsync(() => engine.StartAsync()); + + Assert.Equal(ProcessingEngineState.Faulted, engine.State); + Assert.True(provider.Disposed); + Assert.True(receiver.Disposed); + Assert.Equal(["failed", "stopped"], events); + } + + /// 接收阶段失败时,提供阶段应停止等待并释放全部资源。 + [Fact] + public async Task StartAsync_ReceiverFailure_InterruptsProvider() + { + var provider = new WaitingProvider(CreateMetadata()); + var receiver = new FailingReceiver(); + var engine = ProcessingEngine.Builder() + .UseProvider(provider) + .UseReceiver(receiver) + .Build(); + + await Assert.ThrowsAsync(() => engine.StartAsync()); + + Assert.Equal(ProcessingEngineState.Faulted, engine.State); + Assert.True(provider.Disposed); + Assert.True(receiver.Disposed); + } + + /// 外部取消应中断各阶段,并将引擎置为已取消状态。 + [Fact] + public async Task StartAsync_ExternalCancellation_InterruptsAllStages() + { + var provider = new WaitingProvider(CreateMetadata()); + var receiver = new CancellationObservingReceiver(); + var engine = ProcessingEngine.Builder() + .UseProvider(provider) + .UseReceiver(receiver) + .Build(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + await Assert.ThrowsAnyAsync(() => engine.StartAsync(cancellation.Token)); + + Assert.Equal(ProcessingEngineState.Canceled, engine.State); + Assert.True(provider.Canceled); + Assert.True(provider.Disposed); + Assert.True(receiver.Canceled); + Assert.True(receiver.Disposed); + } + + /// 多个数据块经由有界通道时,接收顺序必须保持提供顺序。 + [Fact] + public async Task StartAsync_PreservesOrderAcrossMultipleBlocks() + { + var receiver = new CollectingReceiver(); + var engine = ProcessingEngine.Builder() + .UseProvider(new MultipleBlockProvider(CreateMetadata(), 16)) + .AddProcessor(new AppendProcessor(255)) + .UseReceiver(receiver) + .Build(); + + await engine.StartAsync(); + + Assert.Equal(Enumerable.Range(0, 16).Select(i => (byte)i), + receiver.Blocks.Select(block => block.Data.Span[0])); + Assert.All(receiver.Blocks, block => Assert.Equal(255, block.Data.Span[1])); + foreach (var block in receiver.Blocks) block.Dispose(); + } + + /// 无输出接收阶段应完整消费并释放数据流,使验证类流程能够正常完成。 + [Fact] + public async Task StartAsync_NullDataReceiver_DrainsPipeline() + { + var processor = new AppendProcessor(7); + var engine = ProcessingEngine.Builder() + .UseProvider(new MultipleBlockProvider(CreateMetadata(), 4)) + .AddProcessor(processor) + .UseReceiver(new NullDataReceiver()) + .Build(); + + await engine.StartAsync(); + + Assert.Equal(ProcessingEngineState.Completed, engine.State); + Assert.Equal(4, processor.LifeCycle.Count(stage => stage == "process")); + Assert.Contains("dispose", processor.LifeCycle); + } + + /// 观察器应通过 Completed Hook 捕获强类型运行记录与运行状态。 + [Fact] + public async Task Observer_CapturesTypedRecordFromCompletedHook() + { + var key = new EngineRecordKey("processed-blocks"); + var processor = new AppendProcessor(1); + var engine = ProcessingEngine.Builder() + .UseProvider(new SingleBlockProvider(CreateMetadata(), 1)) + .AddProcessor(processor) + .UseReceiver(new NullDataReceiver()) + .Build(); + using var observer = new ProcessingEngineObserver(engine); + observer.Capture(key); + + await observer.RunAsync(); + + Assert.NotNull(observer.StartedAt); + Assert.NotNull(observer.StoppedAt); + Assert.Null(observer.Exception); + Assert.True(observer.TryGetCaptured(key, out var processedBlocks)); + Assert.Equal(1, processedBlocks); + } + private static BlockMetadata CreateMetadata() { return new BlockMetadata @@ -127,6 +306,41 @@ protected override async Task ProduceCoreAsync(CancellationToken cancellationTok } } + /// 连续提供多个块的测试提供阶段。 + private sealed class MultipleBlockProvider(BlockMetadata metadata, int count) : DataProviderBase + { + /// + protected override async Task ProduceCoreAsync(CancellationToken cancellationToken = default) + { + for (var i = 0; i < count; i++) + { + var buffer = ArrayPool.Shared.Rent(1); + buffer[0] = (byte)i; + await WriteAsync(new DataBlock(buffer, 1, metadata), cancellationToken); + } + } + } + + /// 启动后立即失败的测试提供阶段。 + private sealed class FailingProvider : DataProviderBase + { + /// 是否已释放。 + public bool Disposed { get; private set; } + + /// + protected override Task ProduceCoreAsync(CancellationToken cancellationToken = default) + { + return Task.FromException(new InvalidDataException("测试提供者失败。")); + } + + /// + public override Task DisposeAsync(CancellationToken cancellationToken = default) + { + Disposed = true; + return Task.CompletedTask; + } + } + /// 在数据尾部追加一个字节的测试处理阶段。 private sealed class AppendProcessor(byte value) : DataProcessorBase { @@ -197,6 +411,58 @@ public override async Task ReceiveAsync(CancellationToken cancellationToken = de } } + /// 记录启动顺序的空提供阶段。 + private sealed class StartOrderProvider(List order) : DataProviderBase + { + /// + public override Task StartAsync(CancellationToken cancellationToken = default) + { + order.Add("provider"); + return Task.CompletedTask; + } + + /// + protected override Task ProduceCoreAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } + + /// 记录启动顺序的空处理阶段。 + private sealed class StartOrderProcessor(List order, string name) : DataProcessorBase + { + /// + public override Task StartAsync(CancellationToken cancellationToken = default) + { + order.Add(name); + return Task.CompletedTask; + } + + /// + protected override ValueTask ProcessBlockAsync(DataBlock block, + CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(null); + } + } + + /// 记录启动顺序的空接收阶段。 + private sealed class StartOrderReceiver(List order) : DataReceiverBase + { + /// + public override Task StartAsync(CancellationToken cancellationToken = default) + { + order.Add("receiver"); + return Task.CompletedTask; + } + + /// + public override async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + await foreach (var block in ReadAsync(cancellationToken)) block.Dispose(); + } + } + /// 写出首个块后等待引擎取消的测试提供阶段。 private sealed class WaitingProvider(BlockMetadata metadata) : DataProviderBase { @@ -239,8 +505,10 @@ private sealed class FailingProcessor : DataProcessorBase /// protected override ValueTask ProcessBlockAsync(DataBlock block, - CancellationToken cancellationToken = default) => - ValueTask.FromException(new InvalidDataException("测试处理器失败。")); + CancellationToken cancellationToken = default) + { + return ValueTask.FromException(new InvalidDataException("测试处理器失败。")); + } /// public override Task DisposeAsync(CancellationToken cancellationToken = default) @@ -281,4 +549,24 @@ public override Task DisposeAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } } -} + + /// 启动后立即失败的测试接收阶段。 + private sealed class FailingReceiver : DataReceiverBase + { + /// 是否已释放。 + public bool Disposed { get; private set; } + + /// + public override Task ReceiveAsync(CancellationToken cancellationToken = default) + { + return Task.FromException(new InvalidDataException("测试接收者失败。")); + } + + /// + public override Task DisposeAsync(CancellationToken cancellationToken = default) + { + Disposed = true; + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/CrypVol.Tests/FileEntryHeaderTests.cs b/CrypVol.Tests/FileEntryHeaderTests.cs index 2f240df..27664ea 100644 --- a/CrypVol.Tests/FileEntryHeaderTests.cs +++ b/CrypVol.Tests/FileEntryHeaderTests.cs @@ -1,6 +1,5 @@ using System.Security.Cryptography; using System.Text; -using CrypVol.Lib; using CrypVol.Lib.Volume; using Xunit; diff --git a/CrypVol.Tests/IntegrityLevelTests.cs b/CrypVol.Tests/IntegrityLevelTests.cs index db114b7..6136a6f 100644 --- a/CrypVol.Tests/IntegrityLevelTests.cs +++ b/CrypVol.Tests/IntegrityLevelTests.cs @@ -1,4 +1,3 @@ -using CrypVol.Lib; using CrypVol.Lib.Volume; using Xunit; @@ -20,7 +19,10 @@ public void SetGet_RoundTrip_AllValues() [Fact] public void SetIntegrityLevel_PreservesFragmentFlags() { - var header = new FileEntryHeader { Flags = 3 }; + var header = new FileEntryHeader + { + Flags = 3 + }; header.SetIntegrityLevel(IntegrityLevel.Block); Assert.Equal(3, header.Flags & 3); @@ -35,4 +37,4 @@ public void ToBytes_PreservesIntegrityLevel() Assert.Equal(IntegrityLevel.File, (IntegrityLevel)(header.ToBytes()[12] >> 3 & 3)); } -} +} \ No newline at end of file diff --git a/CrypVol.Tests/UtilityTests.cs b/CrypVol.Tests/UtilityTests.cs index e1786d4..8a7d0da 100644 --- a/CrypVol.Tests/UtilityTests.cs +++ b/CrypVol.Tests/UtilityTests.cs @@ -1,7 +1,8 @@ -using CrypVol.Lib; using CrypVol.Lib.Crypto; using CrypVol.Lib.Engine.Models; +using CrypVol.Lib.Helper.Models; using CrypVol.Lib.Utility; +using CrypVol.Lib.Volume; using Xunit; namespace CrypVol.Tests; @@ -89,6 +90,21 @@ public void IntegrityLevel_HasAllValues() { Assert.Equal(4, Enum.GetValues().Length); } + + /// 库直接调用 Pack 时应与 CLI 保持相同的块级完整性默认值。 + [Fact] + public void PackOptions_DefaultIntegrityLevel_IsBlock() + { + var options = new PackOptions + { + SourceFolder = new DirectoryInfo(Path.GetTempPath()), + SourceFiles = Array.Empty(), + OutputDir = new DirectoryInfo(Path.GetTempPath()), + Credentials = new CvkCredentials(EncryptionMode.None, Array.Empty()) + }; + + Assert.Equal(IntegrityLevel.Block, options.IntegrityLevel); + } } public class BrowseOutputModeTests @@ -145,4 +161,4 @@ public void BlockMetadata_Properties_SetCorrectly() Assert.Equal(1, item.Flags); Assert.True(item.IsFirstFragment); } -} +} \ No newline at end of file diff --git a/CrypVol.Tests/VolumeScannerTests.cs b/CrypVol.Tests/VolumeScannerTests.cs index 9abcf48..8aa17e4 100644 --- a/CrypVol.Tests/VolumeScannerTests.cs +++ b/CrypVol.Tests/VolumeScannerTests.cs @@ -88,7 +88,7 @@ public void Scan_PlainMode_SingleFile_SingleFragment() Assert.Equal(data.Length, fragments[0].BlockSize); Assert.Equal(data.Length, fragments[0].TotalFileSize); Assert.Equal(0, fragments[0].Flags); - Assert.True(fragments[0].IsFirst); + Assert.True(fragments[0].StartsEntry); } finally { @@ -142,6 +142,48 @@ public void Scan_PlainMode_MultipleFiles() } } + /// 同一条目中的后续块不应重复声明起始头,且扫描结果必须保留物理顺序。 + [Fact] + public void Scan_PlainMode_MultipleBlocksInEntry_PreservesEntryBoundaryAndOrder() + { + var dir = new DirectoryInfo(TempDir()); + try + { + dir.Create(); + var path = Path.Combine(dir.FullName, "archive.0.cvp"); + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + var header = new FileEntryHeader + { + FileId = 1, + Flags = 0, + FragmentIndex = 0, + SizeOrTotal = 3 + }; + header.SetFilePath("joined.bin"); + stream.Write(header.ToBytes()); + stream.Write(BitConverter.GetBytes(2)); + stream.Write([1, 2]); + stream.Write(BitConverter.GetBytes(1)); + stream.Write([3]); + } + + var result = VolumeScanner.Scan([new FileInfo(path)]); + + var fragments = result.Files["joined.bin"]; + Assert.Equal(2, fragments.Count); + Assert.True(fragments[0].StartsEntry); + Assert.False(fragments[1].StartsEntry); + Assert.Equal(["joined.bin", "joined.bin"], result.OrderedBlocks.Select(block => block.RelativePath)); + Assert.Equal(fragments, result.OrderedBlocks.Select(block => block.Fragment)); + } + finally + { + try { dir.Delete(true); } + catch { } + } + } + [Fact] public void Scan_PlainMode_EmptyFile_ReturnsEmpty() { @@ -190,6 +232,69 @@ public void Scan_PlainMode_NoMagic_ReturnsEmpty() } } + /// 不可信卷头不得通过父目录跳出解包根目录。 + [Fact] + public void Scan_PlainMode_ParentDirectoryPath_ThrowsInvalidDataException() + { + var dir = new DirectoryInfo(TempDir()); + try + { + dir.Create(); + var cvp = WritePlainCvp(dir, "archive.0.cvp", "../outside.txt", [1]); + + Assert.Throws(() => VolumeScanner.Scan([cvp])); + } + finally + { + try { dir.Delete(true); } + catch { } + } + } + + /// 长度字段超出卷剩余内容时,扫描器必须在创建片段前拒绝该卷。 + [Fact] + public void Scan_PlainMode_TruncatedBlock_ThrowsInvalidDataException() + { + var dir = new DirectoryInfo(TempDir()); + try + { + dir.Create(); + var path = Path.Combine(dir.FullName, "truncated.0.cvp"); + using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write)) + { + var header = new FileEntryHeader + { + FileId = 1, + Flags = 0, + FragmentIndex = 0, + SizeOrTotal = 100 + }; + header.SetFilePath("truncated.bin"); + stream.Write(header.ToBytes()); + stream.Write(BitConverter.GetBytes(100)); + stream.Write([1]); + } + + Assert.Throws(() => VolumeScanner.Scan([new FileInfo(path)])); + } + finally + { + try { dir.Delete(true); } + catch { } + } + } + + /// 输出解析必须拒绝越出根目录的绝对路径与相对路径。 + [Fact] + public void VolumePathSafety_ResolveUnderRoot_RejectsEscapingPath() + { + var root = Path.Combine(Path.GetTempPath(), $"cvp-output-{Guid.NewGuid()}"); + + Assert.Throws(() => VolumePathSafety.ResolveUnderRoot(root, "../outside.txt")); + Assert.Throws(() => VolumePathSafety.ResolveUnderRoot(root, Path.GetTempPath())); + Assert.Equal(Path.Combine(root, "safe.txt"), VolumePathSafety.ResolveUnderRoot(root, "safe.txt")); + } + // ── Scan: Encrypted mode ── [Fact] @@ -264,7 +369,7 @@ public void Fragment_CorrectProperties() Assert.Equal(100, fragment.BlockSize); Assert.Equal(5000, fragment.TotalFileSize); Assert.Equal(1, fragment.Flags); - Assert.True(fragment.IsFirst); + Assert.True(fragment.StartsEntry); } // ── Scan: multiple volumes ── diff --git a/FEATURE.md b/FEATURE.md index ca539a8..1cde198 100644 --- a/FEATURE.md +++ b/FEATURE.md @@ -38,7 +38,7 @@ | `None` | 数据明文,无 `.cvk` | — | | `PlainKey` | CEK 明文存 `.cvk` | `.cvk` 文件 | | `Password` | Argon2id + AES-256-GCM 包裹 | 密码 | -| `Asymmetric` | RSA/ECC 公钥包裹 | 对应私钥 | +| `Asymmetric` | RSA 公钥包裹 | 对应私钥 | ## 文件格式 diff --git a/README.md b/README.md index d8542af..8e547ec 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,18 @@ cd CrypVol dotnet build -c Release ``` +本地发布 CLI 与 GUI: + +```bash +bash scripts/publish-local.sh +``` + +默认输出为 `publish/local/linux-x64`。可传入目标目录与 RID: + +```bash +bash scripts/publish-local.sh /opt/crypvol-publish linux-x64 +``` + 开发环境需要 .NET SDK 10。CLI 项目为 `CrypVol.Cli`;构建后可通过 `dotnet run --project CrypVol.Cli -- <命令>` 使用。以下示例以已安装到 PATH 的 `crypvol` 表示。 ```bash @@ -77,7 +89,7 @@ crypvol pack ./data -o ./archive --prefix backup \ # 流式块大小 32 MiB、近似卷目标 512 MiB、启用压缩 crypvol pack ./data -o ./archive --prefix backup \ - --chunk-size 32 --volume-size 512 --compress --compression-level 6 + --chunk-size 32 --volume-size 512 --compression-level Optimal ``` 未指定 `--key-file` 时,`pack` 按 `--mode` 创建新 CEK 和 CVK;指定 `--key-file` 时复用其中的 CEK,`--mode`、`--public-key` 和 `--comment` 不参与新 CVK 的创建。`--dry-run` 只根据原始大小和块大小给出近似卷数/块数,不写入数据或密钥文件。 @@ -100,7 +112,9 @@ crypvol browse ./archive/backup.0.cvp --format Json --output files.json crypvol verify ./archive/backup.0.cvp --repair-report damage-report.txt ``` -加密卷会自动尝试发现同目录、同前缀的 `.cvk`。若无法发现或需指定其他位置,请传入 `--key-file`;Password 模式配合 `--password`,Asymmetric 模式配合 `--privkey-key`,加密私钥另可使用 `--key-pass`。 +加密卷会自动尝试发现同目录、同前缀的 `.cvk`。若无法发现或需指定其他位置,请传入 `--key-file`;Password 模式配合 `--password`。Asymmetric 模式会自动尝试当前用户 `~/.ssh` 中与同名 `.pub` 文件成对的私钥,也可用 `--privkey-key` 明确指定;加密私钥另可使用 `--key-pass`。 + +Asymmetric 模式仅使用 RSA:公钥支持 PEM 与 OpenSSH 的 `ssh-rsa` 单行格式;私钥支持 PKCS#1/PKCS#8 PEM、OpenSSH(含密码保护)、ssh.com 和 PuTTY PPK 格式。 ### 3. 修复 diff --git a/scripts/publish-local.sh b/scripts/publish-local.sh new file mode 100644 index 0000000..0b07a57 --- /dev/null +++ b/scripts/publish-local.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_IDENTIFIER="${2:-linux-x64}" +OUTPUT_DIRECTORY="${1:-$REPOSITORY_ROOT/publish/local/$RUNTIME_IDENTIFIER}" + +mkdir -p "$OUTPUT_DIRECTORY/cli" "$OUTPUT_DIRECTORY/gui" + +dotnet publish "$REPOSITORY_ROOT/CrypVol.Cli/CrypVol.Cli.csproj" \ + --configuration Release \ + --runtime "$RUNTIME_IDENTIFIER" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:PublishTrimmed=true \ + --output "$OUTPUT_DIRECTORY/cli" + +dotnet publish "$REPOSITORY_ROOT/CrypVol/CrypVol.csproj" \ + --configuration Release \ + --runtime "$RUNTIME_IDENTIFIER" \ + --self-contained true \ + -p:PublishSingleFile=true \ + --output "$OUTPUT_DIRECTORY/gui" + +printf '本地发布完成: %s\n' "$OUTPUT_DIRECTORY" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index f9b848f..34498fa 100644 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -162,8 +162,13 @@ check "--key-file 复用" run pack "$TMP/pack-reuse/input" \ CMP_DIR="$TMP/pack-compress" mkdir -p "$CMP_DIR/input" "$CMP_DIR/output" mk_files "$CMP_DIR/input" -check "-c 压缩" run pack "$CMP_DIR/input" -m PlainKey -c -o "$CMP_DIR/output" -check "--compression-level 9" run pack "$CMP_DIR/input" -m PlainKey -c --compression-level 9 -o "$TMP/pack-cmp9" +check "默认压缩" run pack "$CMP_DIR/input" -m PlainKey -o "$CMP_DIR/output" +check "--compression-level SmallestSize" run pack "$CMP_DIR/input" -m PlainKey --compression-level SmallestSize -o "$TMP/pack-cmp9" +check "--compression-level Fastest" run pack "$CMP_DIR/input" -m PlainKey --compression-level Fastest -o "$TMP/pack-cmp5" +check "--integrity File" run pack "$CMP_DIR/input" -m PlainKey --integrity File -o "$TMP/pack-integrity-file" +check "integrity File verify" run verify "$TMP/pack-integrity-file" -k "$TMP/pack-integrity-file/input.cvk" +check_fail "--chunk-size 0" run pack "$CMP_DIR/input" -m None --chunk-size 0 -o "$TMP/pack-invalid-chunk" +check_fail "--compression-level 非法预设" run pack "$CMP_DIR/input" -m PlainKey --compression-level 10 -o "$TMP/pack-invalid-compression" # --include mkdir -p "$TMP/pack-filter/input" "$TMP/pack-filter/output" @@ -247,11 +252,27 @@ check "browse --exclude" run browse "$B" --exclude "nonexistent*" check_fail "browse 不存在目录" run browse "$TMP/_noexist" # ═══════════════════════════════════════ -section "5. info — 密钥信息" +section "5. verify / repair — 报告定位" +# ═══════════════════════════════════════ +printf '\245' | dd of="$SPLIT_DIR/output/split.1.cvp" bs=1 seek=600 count=1 conv=notrunc status=none +REPORT="$TMP/reports/nested/damage.txt" +check_fail "verify 写入嵌套报告" run verify "$SPLIT_DIR/output/split.1.cvp" \ + -k "$SPLIT_DIR/output/split.cvk" --repair-report "$REPORT" +check "损坏报告已创建" test -s "$REPORT" +check "repair 按卷定位" run repair "$SPLIT_DIR/output/split.1.cvp" \ + -k "$SPLIT_DIR/output/split.cvk" --verify-report "$REPORT" -o "$TMP/repaired-report" +check "报告修复输出" test -f "$TMP/repaired-report/split.1.cvp" +check "修复后 CRC 校验" run verify "$TMP/repaired-report/split.1.cvp" \ + -k "$SPLIT_DIR/output/split.cvk" + +# ═══════════════════════════════════════ +section "6. info — 密钥信息" # ═══════════════════════════════════════ check_out "info PlainKey" "明文" run info "$TMP/k-plain.cvk" check_out "info Password" "密码保护" run info "$TMP/k-pass.cvk" -p "pwd123" +check_out "info 编码模式" "封装模式: 密码保护" run info "$TMP/k-pass.cvk" -p "pwd123" check_out "info -p 正确密码" "CEK" run info "$TMP/k-pass.cvk" -p "pwd123" +check_out "info 注释内容" "注释: test" run info "$TMP/k-cmt.cvk" -p "pwd" check_fail "info 非法文件" run info "$TMP/not-a-key.txt" check_fail "info 不存在文件" run info "$TMP/_nokey.cvk" @@ -260,7 +281,7 @@ if [ -f "$TMP/k-asym.cvk" ]; then fi # ═══════════════════════════════════════ -section "6. rekey — 密钥重包装" +section "7. rekey — 密钥重包装" # ═══════════════════════════════════════ check "Plain→Password" run rekey "$TMP/k-plain.cvk" --to-mode Password --new-password "rp" -o "$TMP/k-rp.cvk" check "Password→Plain" run rekey "$TMP/k-pass.cvk" -p "pwd123" --to-mode PlainKey -o "$TMP/k-back.cvk" @@ -284,7 +305,7 @@ verify_extract "rekey后extract" "${PACK_DIR[PlainKey]}/output" "$TMP/ext-rekey" check "rekey --backup" run rekey "$TMP/k-rp2.cvk" --to-mode Password --new-password "bp" -b # ═══════════════════════════════════════ -section "7. convert — 块级密钥轮换" +section "8. convert — 块级密钥轮换" # ═══════════════════════════════════════ CV_DIR="$TMP/convert" mkdir -p "$CV_DIR/input" "$CV_DIR/output" "$CV_DIR/conv" "$CV_DIR/restored" @@ -317,7 +338,7 @@ check "convert --backup" run convert "$CV2_DIR/out" \ --key-file "$TMP/k-plain.cvk" -o "$TMP/conv-bak" -b # ═══════════════════════════════════════ -section "8. 凭据校验" +section "9. 凭据校验" # ═══════════════════════════════════════ PD="${PACK_DIR[Password]}/output" check_fail "extract 缺密码" run extract "$PD" -k "$PD/Password.cvk" -o "$TMP/_nopwd" @@ -330,7 +351,7 @@ check_out "info 正确密码" "CEK" run info "$TMP/k-pass.cvk" -p "pwd123" check_fail "rekey 错旧密码" run rekey "$TMP/k-pass.cvk" -p "bad" --to-mode PlainKey -o "$TMP/_" # ═══════════════════════════════════════ -section "9. 大文件 100MB" +section "10. 大文件 100MB" # ═══════════════════════════════════════ BIG_DIR="$TMP/big" mkdir -p "$BIG_DIR/input" "$BIG_DIR/output" "$BIG_DIR/restored" @@ -340,7 +361,7 @@ verify_extract "100MB extract" "$BIG_DIR/output" "$BIG_DIR/restored" "$BIG_DIR/i "-k $BIG_DIR/output/input.cvk" # ═══════════════════════════════════════ -section "10. 边界场景" +section "11. 边界场景" # ═══════════════════════════════════════ # 空目录