diff --git a/src/WCTDataMiner.Core/Security/PathValidator.cs b/src/WCTDataMiner.Core/Security/PathValidator.cs
new file mode 100644
index 0000000..4177175
--- /dev/null
+++ b/src/WCTDataMiner.Core/Security/PathValidator.cs
@@ -0,0 +1,157 @@
+using System.Text.RegularExpressions;
+
+namespace WCTDataMiner.Core.Security;
+
+///
+/// 路径验证结果
+///
+public class PathValidationResult
+{
+ public bool IsValid { get; }
+ public string? NormalizedPath { get; }
+ public string? ErrorMessage { get; }
+
+ private PathValidationResult(bool isValid, string? normalizedPath, string? errorMessage)
+ {
+ IsValid = isValid;
+ NormalizedPath = normalizedPath;
+ ErrorMessage = errorMessage;
+ }
+
+ public static PathValidationResult Success(string normalizedPath)
+ => new(true, normalizedPath, null);
+
+ public static PathValidationResult Fail(string errorMessage)
+ => new(false, null, errorMessage);
+}
+
+///
+/// 路径验证器 - 防止路径遍历攻击
+///
+public class PathValidator
+{
+ private readonly string _applicationBasePath;
+ private readonly HashSet _allowedDirectories;
+
+ ///
+ /// 默认允许的输出目录
+ ///
+ private static readonly string[] DefaultAllowedDirectories = ["exports", "output", "data/exports"];
+
+ public PathValidator(string applicationBasePath, IEnumerable? allowedDirectories = null)
+ {
+ _applicationBasePath = Path.GetFullPath(applicationBasePath);
+ _allowedDirectories = new HashSet(
+ (allowedDirectories ?? DefaultAllowedDirectories).Select(d => d.Replace('/', Path.DirectorySeparatorChar)),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// 验证输出目录路径
+ ///
+ /// 用户输入的输出目录路径
+ /// 验证结果
+ public PathValidationResult ValidateOutputDirectory(string outputDir)
+ {
+ // 1. 基础验证
+ if (string.IsNullOrWhiteSpace(outputDir))
+ return PathValidationResult.Fail("输出目录不能为空");
+
+ // 2. 禁止的模式检查
+ var forbiddenPatterns = new[]
+ {
+ "..", // 路径遍历
+ "~", // 用户主目录
+ "\\\\", // UNC 路径
+ };
+
+ foreach (var pattern in forbiddenPatterns)
+ {
+ if (outputDir.Contains(pattern))
+ {
+ return PathValidationResult.Fail($"路径包含禁止的模式: {pattern}");
+ }
+ }
+
+ // 3. 检查是否为绝对路径(Windows 和 Unix)
+ if (Path.IsPathRooted(outputDir) ||
+ Regex.IsMatch(outputDir, @"^[A-Za-z]:") || // Windows 驱动器
+ outputDir.StartsWith("/")) // Unix 绝对路径
+ {
+ return PathValidationResult.Fail("仅允许相对路径");
+ }
+
+ // 4. 规范化并验证
+ string normalizedPath;
+ try
+ {
+ normalizedPath = Path.GetFullPath(Path.Combine(_applicationBasePath, outputDir));
+ }
+ catch (Exception ex)
+ {
+ return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
+ }
+
+ // 5. 严格验证必须在应用目录内
+ if (!normalizedPath.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
+ {
+ return PathValidationResult.Fail("路径必须在应用程序目录内");
+ }
+
+ // 6. 白名单检查
+ var relative = normalizedPath
+ .Substring(_applicationBasePath.Length)
+ .TrimStart(Path.DirectorySeparatorChar);
+
+ bool inAllowedDir = _allowedDirectories
+ .Any(allowed => relative.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(relative, allowed, StringComparison.OrdinalIgnoreCase));
+
+ if (!inAllowedDir)
+ {
+ var allowedList = string.Join(", ", _allowedDirectories);
+ return PathValidationResult.Fail($"输出目录必须在允许列表中: {allowedList}");
+ }
+
+ return PathValidationResult.Success(normalizedPath);
+ }
+
+ ///
+ /// 简化版验证 - 仅检查路径安全性,不做白名单检查
+ ///
+ public PathValidationResult ValidatePathSafety(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return PathValidationResult.Fail("路径不能为空");
+
+ // 检查路径遍历
+ if (path.Contains(".."))
+ return PathValidationResult.Fail("路径包含非法的遍历字符");
+
+ // 检查绝对路径
+ if (Path.IsPathRooted(path) ||
+ Regex.IsMatch(path, @"^[A-Za-z]:") ||
+ path.StartsWith("/"))
+ {
+ return PathValidationResult.Fail("仅允许相对路径");
+ }
+
+ // 检查 UNC 路径
+ if (path.StartsWith("\\\\"))
+ return PathValidationResult.Fail("不允许 UNC 路径");
+
+ // 规范化验证
+ try
+ {
+ var normalized = Path.GetFullPath(Path.Combine(_applicationBasePath, path));
+ if (!normalized.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
+ return PathValidationResult.Fail("路径超出应用程序目录范围");
+ }
+ catch (Exception ex)
+ {
+ return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
+ }
+
+ return PathValidationResult.Success(path);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner.Core/Security/SecurityConstants.cs b/src/WCTDataMiner.Core/Security/SecurityConstants.cs
new file mode 100644
index 0000000..3a2d8ff
--- /dev/null
+++ b/src/WCTDataMiner.Core/Security/SecurityConstants.cs
@@ -0,0 +1,54 @@
+namespace WCTDataMiner.Core.Security;
+
+///
+/// 安全相关常量定义
+///
+public static class SecurityConstants
+{
+ ///
+ /// 默认允许的输出目录
+ ///
+ public static readonly string[] DefaultAllowedExportDirectories =
+ [
+ "exports",
+ "output",
+ "data/exports",
+ "data/output"
+ ];
+
+ ///
+ /// 路径最大长度限制
+ ///
+ public const int MaxPathLength = 260;
+
+ ///
+ /// 文件名最大长度限制
+ ///
+ public const int MaxFileNameLength = 255;
+
+ ///
+ /// 禁止的路径模式
+ ///
+ public static readonly string[] ForbiddenPathPatterns =
+ [
+ "..", // 路径遍历
+ "~", // 用户主目录
+ "\\\\", // UNC 路径开始
+ "|", // 管道字符(Windows 禁止)
+ ">", // 重定向字符
+ "<", // 重定向字符
+ "*", // 通配符(用于文件名时)
+ "?" // 通配符(用于文件名时)
+ ];
+
+ ///
+ /// 安全的文件扩展名白名单
+ ///
+ public static readonly string[] AllowedExportExtensions =
+ [
+ ".csv",
+ ".json",
+ ".txt",
+ ".log"
+ ];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner.Core/Services/ExportService.cs b/src/WCTDataMiner.Core/Services/ExportService.cs
index e33da3f..df95ca5 100644
--- a/src/WCTDataMiner.Core/Services/ExportService.cs
+++ b/src/WCTDataMiner.Core/Services/ExportService.cs
@@ -2,21 +2,27 @@ using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
using WCTDataMiner.Core.Data;
using WCTDataMiner.Core.Models;
+using WCTDataMiner.Core.Security;
namespace WCTDataMiner.Core.Services;
///
-/// 数据导出服务
+/// 数据导出服务 - 支持安全的路径验证和 RFC 4180 标准 CSV 转义
///
public class ExportService
{
private readonly WctMinerDbContext _context;
+ private readonly ILogger _logger;
+ private readonly PathValidator _pathValidator;
- public ExportService(WctMinerDbContext context)
+ public ExportService(WctMinerDbContext context, ILogger logger)
{
_context = context;
+ _logger = logger;
+ _pathValidator = new PathValidator(AppDomain.CurrentDomain.BaseDirectory);
}
///
@@ -24,30 +30,72 @@ public class ExportService
///
public async Task ExportQfodToCsvAsync(string outputDir)
{
- Directory.CreateDirectory(outputDir);
- var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
+ _logger.LogInformation("开始导出 Qfod 数据为 CSV");
- var records = await _context.QfodRecords
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxPanel)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxHardware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxSoftware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.RxType)
- .ToListAsync();
-
- var sb = new StringBuilder();
- // CSV Header
- sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
-
- foreach (var r in records)
+ // 路径验证
+ var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
+ if (!validationResult.IsValid)
{
- sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.ChargerIndex},{r.CoilIndex},{r.DeltaQ},{r.CurrentQ},{r.RawQ},{r.FodType}");
+ throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
- await File.WriteAllTextAsync(filePath, sb.ToString());
+ var safeOutputDir = validationResult.NormalizedPath!;
+ Directory.CreateDirectory(safeOutputDir);
+ var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
+
+ // 使用流式导出避免内存问题
+ await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
+
+ // CSV Header
+ await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
+
+ // 使用 Select 投影避免 N+1 查询
+ var query = _context.QfodRecords
+ .Select(r => new
+ {
+ r.Id,
+ r.ScenarioId,
+ TxPanelName = r.Scenario!.TxPanel.Name,
+ TxHardwareVersion = r.Scenario.TxHardware.Version,
+ TxSoftwareVersion = r.Scenario.TxSoftware.Version,
+ RxTypeName = r.Scenario.RxType.Name,
+ r.Scenario.TestPurpose,
+ r.Scenario.TestDate,
+ r.Scenario.TestSequence,
+ r.ChargerIndex,
+ r.CoilIndex,
+ r.DeltaQ,
+ r.CurrentQ,
+ r.RawQ,
+ r.FodType
+ })
+ .OrderBy(r => r.Id);
+
+ int count = 0;
+ await foreach (var r in query.AsAsyncEnumerable())
+ {
+ var line = string.Join(',',
+ EscapeCsvField(r.Id),
+ EscapeCsvField(r.ScenarioId),
+ EscapeCsvField(r.TxPanelName),
+ EscapeCsvField(r.TxHardwareVersion),
+ EscapeCsvField(r.TxSoftwareVersion),
+ EscapeCsvField(r.RxTypeName),
+ EscapeCsvField(r.TestPurpose),
+ EscapeCsvField(r.TestDate),
+ EscapeCsvField(r.TestSequence),
+ EscapeCsvField(r.ChargerIndex),
+ EscapeCsvField(r.CoilIndex),
+ EscapeCsvField(r.DeltaQ),
+ EscapeCsvField(r.CurrentQ),
+ EscapeCsvField(r.RawQ),
+ EscapeCsvField(r.FodType)
+ );
+ await writer.WriteLineAsync(line);
+ count++;
+ }
+
+ _logger.LogInformation("Qfod CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
return filePath;
}
@@ -56,30 +104,88 @@ public class ExportService
///
public async Task ExportPlossToCsvAsync(string outputDir)
{
- Directory.CreateDirectory(outputDir);
- var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
+ _logger.LogInformation("开始导出 Ploss 数据为 CSV");
- var records = await _context.PlossRecords
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxPanel)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxHardware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxSoftware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.RxType)
- .ToListAsync();
-
- var sb = new StringBuilder();
- // CSV Header
- sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
-
- foreach (var r in records)
+ // 路径验证
+ var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
+ if (!validationResult.IsValid)
{
- sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.RxType},{r.RxPower},{r.TxPower},{r.Vcoil},{r.Vin},{r.Isns},{r.Ploss},{r.Threshold},{r.TriggerCount},{r.FodResult},{r.ProtocolType},{r.PwmDuty},{r.CoilIndex},{r.Margin}");
+ throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
}
- await File.WriteAllTextAsync(filePath, sb.ToString());
+ var safeOutputDir = validationResult.NormalizedPath!;
+ Directory.CreateDirectory(safeOutputDir);
+ var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
+
+ // 使用流式导出避免内存问题
+ await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
+
+ // CSV Header
+ await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
+
+ // 使用 Select 投影避免 N+1 查询
+ var query = _context.PlossRecords
+ .Select(r => new
+ {
+ r.Id,
+ r.ScenarioId,
+ TxPanelName = r.Scenario!.TxPanel.Name,
+ TxHardwareVersion = r.Scenario.TxHardware.Version,
+ TxSoftwareVersion = r.Scenario.TxSoftware.Version,
+ RxTypeName = r.Scenario.RxType.Name,
+ r.Scenario.TestPurpose,
+ r.Scenario.TestDate,
+ r.Scenario.TestSequence,
+ r.RxType,
+ r.RxPower,
+ r.TxPower,
+ r.Vcoil,
+ r.Vin,
+ r.Isns,
+ r.Ploss,
+ r.Threshold,
+ r.TriggerCount,
+ r.FodResult,
+ r.ProtocolType,
+ r.PwmDuty,
+ r.CoilIndex,
+ Margin = r.Threshold - r.Ploss
+ })
+ .OrderBy(r => r.Id);
+
+ int count = 0;
+ await foreach (var r in query.AsAsyncEnumerable())
+ {
+ var line = string.Join(',',
+ EscapeCsvField(r.Id),
+ EscapeCsvField(r.ScenarioId),
+ EscapeCsvField(r.TxPanelName),
+ EscapeCsvField(r.TxHardwareVersion),
+ EscapeCsvField(r.TxSoftwareVersion),
+ EscapeCsvField(r.RxTypeName),
+ EscapeCsvField(r.TestPurpose),
+ EscapeCsvField(r.TestDate),
+ EscapeCsvField(r.TestSequence),
+ EscapeCsvField(r.RxType),
+ EscapeCsvField(r.RxPower),
+ EscapeCsvField(r.TxPower),
+ EscapeCsvField(r.Vcoil),
+ EscapeCsvField(r.Vin),
+ EscapeCsvField(r.Isns),
+ EscapeCsvField(r.Ploss),
+ EscapeCsvField(r.Threshold),
+ EscapeCsvField(r.TriggerCount),
+ EscapeCsvField(r.FodResult),
+ EscapeCsvField(r.ProtocolType),
+ EscapeCsvField(r.PwmDuty),
+ EscapeCsvField(r.CoilIndex),
+ EscapeCsvField(r.Margin)
+ );
+ await writer.WriteLineAsync(line);
+ count++;
+ }
+
+ _logger.LogInformation("Ploss CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
return filePath;
}
@@ -88,18 +194,20 @@ public class ExportService
///
public async Task ExportQfodToJsonAsync(string outputDir)
{
- Directory.CreateDirectory(outputDir);
- var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
+ _logger.LogInformation("开始导出 Qfod 数据为 JSON");
+
+ // 路径验证
+ var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
+ if (!validationResult.IsValid)
+ {
+ throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
+ }
+
+ var safeOutputDir = validationResult.NormalizedPath!;
+ Directory.CreateDirectory(safeOutputDir);
+ var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.QfodRecords
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxPanel)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxHardware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxSoftware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
@@ -122,6 +230,8 @@ public class ExportService
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
+
+ _logger.LogInformation("Qfod JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
return filePath;
}
@@ -130,18 +240,20 @@ public class ExportService
///
public async Task ExportPlossToJsonAsync(string outputDir)
{
- Directory.CreateDirectory(outputDir);
- var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
+ _logger.LogInformation("开始导出 Ploss 数据为 JSON");
+
+ // 路径验证
+ var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
+ if (!validationResult.IsValid)
+ {
+ throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
+ }
+
+ var safeOutputDir = validationResult.NormalizedPath!;
+ Directory.CreateDirectory(safeOutputDir);
+ var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
var records = await _context.PlossRecords
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxPanel)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxHardware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.TxSoftware)
- .Include(r => r.Scenario)
- .ThenInclude(s => s!.RxType)
.Select(r => new
{
r.Id,
@@ -172,6 +284,47 @@ public class ExportService
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
+
+ _logger.LogInformation("Ploss JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
return filePath;
}
+
+ ///
+ /// RFC 4180 标准 CSV 字段转义
+ ///
+ private static string EscapeCsvField(object? value)
+ {
+ var field = value switch
+ {
+ null => string.Empty,
+ DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"),
+ DateOnly d => d.ToString("yyyy-MM-dd"),
+ IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? string.Empty
+ };
+
+ // 单次扫描检测所有特殊字符
+ bool needsEscape = false;
+ foreach (char c in field)
+ {
+ if (c == ',' || c == '"' || c == '\n' || c == '\r')
+ {
+ needsEscape = true;
+ break;
+ }
+ }
+
+ if (!needsEscape) return field;
+
+ // 使用 StringBuilder 避免多次字符串分配
+ var sb = new StringBuilder(field.Length + 10);
+ sb.Append('"');
+ foreach (char c in field)
+ {
+ if (c == '"') sb.Append("\"\"");
+ else sb.Append(c);
+ }
+ sb.Append('"');
+ return sb.ToString();
+ }
}
\ No newline at end of file