refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration

- all authorization rules (owner/creator, per-channel read/write grants, who may
  remove whom, delete rules, structure management) live in ChannelPolicy; a test
  fails if they are reimplemented elsewhere
- ChannelInfo carries what the caller may do (can_*), member and invitation lists
  say who can be removed; clients display it and never derive it
- the host no longer stores or calls the file service: it only signs short-lived
  tickets from space permissions (create/put/get/delete/domain), and keeps an opaque
  fileDomain reference on the channel
- a whole file is registered in one request (last frame header + per-frame length and
  digest) instead of one request per 1 MiB frame; the signed manifest still has to
  match every digest
- drop file quota/threshold, receipts, reconcile and delete-queue code from the host
- contract generator emits constants for enums, error codes, token roles, permissions
  and share states
This commit is contained in:
justme committed 2026-10-19 13:38:40 +08:00
1 parent ccb2c074d6
commit 17638b6e20
36 files changed
+926 -589

No files matched your search

@@ -4,6 +4,13 @@ namespace WebDrop.Core.Tests;
public sealed class ChannelCryptoTests
{
/// <summary>文件域的对象主后端只登记头部和摘要,字节不在这里:一个单帧文件</summary>
private static Task Register(ChannelService svc, Id16 id, Credentials creds, byte[] frame)
{
svc.RegisterFileItem(id, creds, frame.AsMemory(0, Envelope.HeaderSize), [new ChannelService.FrameEntry(frame.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(frame))], null);
return Task.CompletedTask;
}
[Fact]
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
{
@@ -81,7 +88,7 @@ public sealed class ChannelCryptoTests
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
var oldFile = TestEnv.NewId();
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
await Register(env.Service, space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16));
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
@@ -98,22 +105,6 @@ public sealed class ChannelCryptoTests
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
}
[Fact]
public async Task AnUnfinishedUploadCanBeDeletedWithoutASignedEvent()
{
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint) with { RequireSignedContent = true };
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
var member = space.Member(memberEndpoint) with { AccountName = "member", RequireSignedContent = true }; env.Service.Join(space.Id, member);
var file = TestEnv.NewId();
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = file.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
// 只传了第 0 帧,没有末帧:上传被打断
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(file, 0, FrameFlags.Gated, 16), null);
Assert.Throws<WebDropException>(() => env.Service.DeleteItem(space.Id, space.Member(TestEnv.Endpoint()) with { AccountName = "stranger", RequireSignedContent = true }, file));
env.Service.DeleteItem(space.Id, owner, file);
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
}
[Fact]
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
{
@@ -159,25 +159,36 @@ public class ChannelServiceTests : IDisposable
}
[Fact]
public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete()
public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep()
{
_env.Limits.MaxWorkspaceChannelBytes = 200;
var space = _env.Create(_ownerEp, ChannelMode.Raw);
var owner = space.Owner(_ownerEp);
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
var first = TestEnv.NewId(); var second = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
var item = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120);
await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null);
Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null));
// 文件域的对象不能把字节直接交给主后端
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));
Svc.DeleteItem(space.Id, owner, first);
Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null);
Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
// 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要)
byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)];
ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray();
var header = frames[^1].AsMemory(0, Envelope.HeaderSize);
Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created);
Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等
var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]);
Assert.Equal(ErrorCode.Conflict, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧
// 聊天域的对象不能这样登记(它们的字节就存在主后端)
var chatItem = TestEnv.NewId();
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40);
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize),
[new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code);
Svc.DeleteItem(space.Id, owner, item);
}
[Fact]
@@ -0,0 +1,48 @@
using System.Text.RegularExpressions;
namespace WebDrop.Core.Tests;
/// <summary>
/// 权限规则只写在 ChannelPolicy 一处:别的地方只许调用,不许再各写一遍。
/// 这个测试扫描源码,发现在 ChannelPolicy 之外重新判断角色、读写授权就失败,防止重复实现长回来。
/// </summary>
public sealed class PolicySingleSourceTests
{
private static string SourceRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "src", "WebDrop.Core"))) dir = dir.Parent;
return Path.Combine(dir?.FullName ?? throw new InvalidOperationException("找不到 server/src"), "src");
}
// 这些写法只能出现在 ChannelPolicy.cs(数据类的定义和令牌校验除外)
private static readonly (string Name, Regex Pattern)[] Forbidden =
[
("直接读频道访问授权的读写位", new Regex(@"\.(ChatRead|ChatWrite|FilesRead|FilesUpload)\b")),
("直接比较是不是所有者", new Regex(@"Role\s*[!=]=\s*Role\.Owner")),
("自己拼「所有者或创建者」", new Regex(@"AccountName\s*==\s*\w+\.CreatorAccount")),
];
// 令牌校验(凭据层,不是授权规则)和数据定义,允许出现
private static readonly string[] Allowed =
["ChannelPolicy.cs", "WorkspaceCrypto.cs", "RequestAuth.cs", "ShareService.cs"];
[Fact]
public void AuthorizationRulesAreWrittenOnlyInChannelPolicy()
{
var offenders = new List<string>();
foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories))
{
if (file.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) || file.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar)) continue;
if (Allowed.Contains(Path.GetFileName(file))) continue;
var lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].TrimStart().StartsWith("//") || lines[i].TrimStart().StartsWith("///")) continue;
foreach (var (name, pattern) in Forbidden)
if (pattern.IsMatch(lines[i])) offenders.Add($"{Path.GetFileName(file)}:{i + 1} {name}: {lines[i].Trim()}");
}
}
Assert.True(offenders.Count == 0, "这些地方在 ChannelPolicy 之外重新判断了权限,请改成调用 ChannelPolicy:\n" + string.Join("\n", offenders));
}
}
@@ -148,4 +148,54 @@ public sealed class WorkspaceStructureTests
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, []));
}
[Fact]
public void ChannelFileDomainIsAnOpaqueReferenceSetOnceAndNeverInterpreted()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
Assert.Null(env.Service.GetWorkspace(ch.Id, owner).Channels.Single().FileDomain);
var created = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "文件", null, "ab12-CD_34").Channel;
Assert.Equal("ab12-CD_34", created.FileDomain);
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "乱", null, "bad domain!"));
// 创建时没带的,以后可以补一次;补了就不能换成别的(文件已经存在那里了)
var later = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "稍后", null).Channel;
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-2"));
}
[Fact]
public void ChannelInfoStatesWhatTheCallerMayDoSoClientsNeverDeriveIt()
{
var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env;
var admins = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, null, null, [WorkspacePermission.RemoveMembers]).Role;
var movers = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "结构组", null, null, null, [WorkspacePermission.ManageStructure]).Role;
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admins.Id, ["alice", "bob"]);
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, movers.Id, ["carol"]);
// 所有者:全部能力
var asOwner = env.Service.GetChannel(ch.Id, owner);
Assert.True(asOwner.IsOwner); Assert.True(asOwner.CanManageStructure); Assert.True(asOwner.CanDeleteAny); Assert.True(asOwner.CanDeleteOwn);
Assert.True(asOwner.CanCreateChannel && asOwner.CanManageFolders && asOwner.CanInvite && asOwner.CanEditSettings && asOwner.CanRemoveMembers);
// 身份组授予的权限:被授予「管理频道和分组」的不是所有者,却能管理结构;其他所有者专属的操作它仍然做不了
var asCarol = env.Service.GetChannel(ch.Id, carol);
Assert.False(asCarol.IsOwner); Assert.True(asCarol.CanManageStructure); Assert.False(asCarol.CanRemoveMembers);
Assert.False(asCarol.CanCreateChannel || asCarol.CanManageFolders || asCarol.CanInvite || asCarol.CanEditSettings);
var asAlice = env.Service.GetChannel(ch.Id, alice);
Assert.False(asAlice.IsOwner); Assert.False(asAlice.CanManageStructure); Assert.True(asAlice.CanRemoveMembers);
// 默认删除策略:成员只能删自己的
Assert.False(asAlice.CanDeleteAny); Assert.True(asAlice.CanDeleteOwn);
// 谁能移除谁:所有者能移除除自己外的人;被授权的不能移除自己、所有者、同样被授权的人
var byOwner = env.Service.MembersView(ch.Id, owner).ToDictionary(v => v.Member.Username, v => v.CanRemove);
Assert.True(byOwner["alice"]); Assert.True(byOwner["carol"]);
Assert.False(byOwner[env.Service.GetChannel(ch.Id, owner).CreatorAccount!]);
var byAlice = env.Service.MembersView(ch.Id, alice).ToDictionary(v => v.Member.Username, v => v.CanRemove);
Assert.False(byAlice["alice"]); Assert.False(byAlice["bob"]); Assert.True(byAlice["carol"]);
Assert.All(env.Service.MembersView(ch.Id, carol), view => Assert.False(view.CanRemove)); // 没有移除权限
// 展示和执行用同一条规则:展示说不能移除的,真去移除也会被拒绝
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "bob"));
}
}