diff --git a/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs index bf6458d..f2416a8 100644 --- a/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs +++ b/src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs @@ -15,7 +15,7 @@ public class SyncObsidianVaultEndpoint(IMediator mediator) public override void Configure() { Post("/knowledge-bases/{KnowledgeBaseId}/obsidian/sync"); - AllowAnonymous(); + Permissions("knowledge:write"); } public override async Task HandleAsync(ObsidianSyncRequest req, CancellationToken ct) @@ -28,9 +28,3 @@ public class SyncObsidianVaultEndpoint(IMediator mediator) await Send.OkAsync(result, ct); } } - -/// -/// Obsidian vault 同步请求校验。 -/// 注意:目录存在性属于 IO 层校验,留给 handler 处理(避免 validator 内做磁盘 IO); -/// 此处仅校验非空、长度与枚举取值。 -/// diff --git a/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs b/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs index 396bcc8..d3932c2 100644 --- a/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs +++ b/src/modules/rag/Application/Obsidian/Handlers/SyncObsidianVaultCommandHandler.cs @@ -1,9 +1,10 @@ using BuildFlow.Domain.Entities.Document; -using System.Security.Cryptography; using MediatR; using BuildFlow.Application.Obsidian.DTOs; using BuildFlow.Domain.Enums; +using BuildFlow.Infrastructure.Obsidian; using BuildFlow.Shared.Domain.Exceptions; +using BuildFlow.Shared.Domain.Common; using BuildFlow.Infrastructure.Persistence; using BuildFlow.Application.Obsidian.Commands; @@ -12,24 +13,20 @@ namespace BuildFlow.Application.Obsidian.Handlers; /// /// SyncObsidian同步仓库Command处理器。执行业务校验后持久化。 /// -public class SyncObsidianVaultCommandHandler(RagDbContext db) +public class SyncObsidianVaultCommandHandler( + RagDbContext db, + ICurrentUserContext userContext, + ObsidianVaultScanner scanner) : IRequestHandler { - private static readonly string[] ObsidianIgnoreDirs = [".obsidian", ".trash", ".git", "node_modules"]; - public async Task Handle(SyncObsidianVaultCommand request, CancellationToken ct) { - if (!Directory.Exists(request.VaultDirectory)) - throw new BusinessException($"Vault 目录不存在:{request.VaultDirectory}"); - - var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct) + var userId = userContext.GetRequiredUserId(); + var kb = await db.KnowledgeBases.FirstOrDefaultAsync( + x => x.Id == request.KnowledgeBaseId && x.CreatedBy == userId, + ct) ?? throw new NotFoundException(ErrorCode.NotFound, "知识库不存在"); - var vaultRoot = Path.GetFullPath(request.VaultDirectory); - var mdFiles = Directory.EnumerateFiles(vaultRoot, "*.md", SearchOption.AllDirectories) - .Where(f => !ObsidianIgnoreDirs.Any(d => f.Contains(Path.DirectorySeparatorChar + d, StringComparison.OrdinalIgnoreCase))) - .ToList(); - // 加载该知识库已有的 Obsidian 文档(VaultPath -> 实体) var existing = await db.Documents .Where(d => d.KnowledgeBaseId == kb.Id && d.SourceType == DocumentSourceTypeEnum.Obsidian) @@ -38,62 +35,55 @@ public class SyncObsidianVaultCommandHandler(RagDbContext db) var added = 0; var updated = 0; var skipped = 0; + var totalFiles = 0; - foreach (var file in mdFiles) + await foreach (var file in scanner.ScanAsync(request.VaultDirectory, ct)) { - ct.ThrowIfCancellationRequested(); + totalFiles++; - var vaultPath = Path.GetRelativePath(vaultRoot, file).Replace('\\', '/'); - var fileName = Path.GetFileName(file); - var fileBytes = await File.ReadAllBytesAsync(file, ct); - var hash = Convert.ToHexString(SHA256.HashData(fileBytes)).ToLowerInvariant(); - var fileSize = fileBytes.LongLength; - - if (existing.TryGetValue(vaultPath, out var doc)) + if (existing.TryGetValue(file.RelativePath, out var doc)) { // 增量:哈希未变则跳过 - if (doc.ContentHash == hash) + if (doc.ContentHash == file.ContentHash) { skipped++; continue; } // 变更:更新文件路径与哈希,标记为待重新处理 - doc.FilePath = file; - doc.FileName = fileName; - doc.FileSize = fileSize; - doc.ContentHash = hash; + doc.FilePath = file.FullPath; + doc.FileName = file.FileName; + doc.FileSize = file.FileSize; + doc.ContentHash = file.ContentHash; doc.Status = DocumentStatusEnum.Pending; - doc.VaultPath = vaultPath; + doc.VaultPath = file.RelativePath; updated++; + continue; } - else + + db.Documents.Add(new DocumentEntity { - // 新增 - db.Documents.Add(new DocumentEntity - { - KnowledgeBaseId = kb.Id, - Title = fileName, - FileName = fileName, - FilePath = file, - FileSize = fileSize, - ContentType = "text/markdown", - Status = DocumentStatusEnum.Pending, - SourceType = DocumentSourceTypeEnum.Obsidian, - VaultPath = vaultPath, - ContentHash = hash, - ChunkingModeOverride = request.ChunkingModeOverride - }); - added++; - } + KnowledgeBaseId = kb.Id, + Title = file.FileName, + FileName = file.FileName, + FilePath = file.FullPath, + FileSize = file.FileSize, + ContentType = "text/markdown", + Status = DocumentStatusEnum.Pending, + SourceType = DocumentSourceTypeEnum.Obsidian, + VaultPath = file.RelativePath, + ContentHash = file.ContentHash, + ChunkingModeOverride = request.ChunkingModeOverride, + }); + added++; } await db.SaveChangesAsync(ct); return new ObsidianSyncResult( KnowledgeBaseId: kb.Id, - VaultDirectory: vaultRoot, - TotalFiles: mdFiles.Count, + VaultDirectory: Path.GetFullPath(request.VaultDirectory), + TotalFiles: totalFiles, Added: added, Updated: updated, Skipped: skipped, diff --git a/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs new file mode 100644 index 0000000..235aeae --- /dev/null +++ b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs @@ -0,0 +1,38 @@ +namespace BuildFlow.Architecture.Tests; + +using Xunit; + +/// +/// RAG 高风险入口的权限和 CQRS 架构约束。 +/// +public class RagSecurityArchitectureTests +{ + [Fact] + public void ObsidianSyncEndpointShouldRequireKnowledgeWritePermission() + { + var source = ReadSource( + "src/BuildFlow.Host/Endpoints/Rag/Obsidian/SyncObsidianVaultEndpoint.cs"); + + Assert.Contains("Permissions(\"knowledge:write\")", source); + Assert.DoesNotContain("AllowAnonymous()", source); + } + + private static string ReadSource(string relativePath) + { + return File.ReadAllText(Path.Combine( + FindRepoRoot().FullName, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static DirectoryInfo FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx"))) return current; + current = current.Parent; + } + + throw new InvalidOperationException("无法定位 BuildFlow.slnx,无法验证 RAG 安全边界。"); + } +} diff --git a/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs b/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs new file mode 100644 index 0000000..d2f07bc --- /dev/null +++ b/tests/BuildFlow.Rag.Tests/ObsidianSyncSecurityTests.cs @@ -0,0 +1,96 @@ +using BuildFlow.Application.Obsidian.Commands; +using BuildFlow.Application.Obsidian.Handlers; +using BuildFlow.Domain.Entities.KnowledgeBase; +using BuildFlow.Infrastructure.Obsidian; +using BuildFlow.Infrastructure.Persistence; +using BuildFlow.Rag.Tests.Stub; +using BuildFlow.Shared.Domain.Errors; +using BuildFlow.Shared.Domain.Exceptions; +using BuildFlow.Shared.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BuildFlow.Rag.Tests; + +/// +/// Obsidian 同步的知识库所有权和增量行为测试。 +/// +public sealed class ObsidianSyncSecurityTests : IDisposable +{ + private readonly string _allowedRoot = Directory.CreateDirectory(Path.Combine( + Path.GetTempPath(), + $"buildflow-obsidian-sync-{Guid.NewGuid():N}")).FullName; + + [Fact] + public async Task NonOwnerShouldReceiveNotFoundWithoutScanningFiles() + { + var owner = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; + await using var db = CreateDb(owner); + var kb = new KnowledgeBaseEntity { Name = "private" }; + db.KnowledgeBases.Add(kb); + await db.SaveChangesAsync(); + var caller = new TestCurrentUserContext { UserId = Guid.NewGuid().ToString() }; + var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); + + var act = () => handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(x => x.Code == ErrorCode.NotFound); + db.Documents.Should().BeEmpty(); + } + + [Fact] + public async Task OwnerSyncShouldAddThenSkipAnUnchangedFile() + { + var caller = new TestCurrentUserContext(); + await using var db = CreateDb(caller); + var kb = new KnowledgeBaseEntity { Name = "owned" }; + db.KnowledgeBases.Add(kb); + await db.SaveChangesAsync(); + await File.WriteAllTextAsync(Path.Combine(_allowedRoot, "note.md"), "hello"); + var handler = new SyncObsidianVaultCommandHandler(db, caller, CreateScanner()); + + var first = await handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + var second = await handler.Handle( + new SyncObsidianVaultCommand(kb.Id, _allowedRoot), + CancellationToken.None); + + first.Added.Should().Be(1); + second.Skipped.Should().Be(1); + db.Documents.Should().ContainSingle(); + } + + private static RagDbContext CreateDb(TestCurrentUserContext userContext) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"obsidian-sync-{Guid.NewGuid():N}") + .AddInterceptors(new AuditInterceptor(userContext)) + .Options; + return new RagDbContext(options); + } + + private ObsidianVaultScanner CreateScanner() + { + return new ObsidianVaultScanner( + Options.Create(new ObsidianOptions + { + AllowedRoots = [_allowedRoot], + MaxFiles = 100, + MaxFileBytes = 1024 * 1024, + MaxTotalBytes = 10 * 1024 * 1024, + }), + NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(_allowedRoot)) Directory.Delete(_allowedRoot, recursive: true); + } +}