From 88c42b244d96f360a0fe3caf7b62e0a50ce48906 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 28 Sep 2026 10:11:02 +0200 Subject: [PATCH] FTP und RocketChat kennen die benannten Arbeitsbereiche (#718) Gemeinsamer Helfer NamedWorkspaces in Core statt dreier Kopien: unit: und Bereichs-Id als Praefix, Lesen braucht Read, Herunterladen braucht Write, Unbekanntes und Verbotenes antworten gleich. Ein Dateiname aus einer RocketChat-Nachricht waehlt keinen Bereich. Co-Authored-By: Claude Opus 5.5 --- .../Organisation/NamedWorkspaces.cs | 80 +++++++++ .../ClawdDotNet.Tools.FTP.csproj | 4 + src/ClawdDotNet.Tools.FTP/FtpTool.cs | 42 +++-- src/ClawdDotNet.Tools.FileRW/FileRWTool.cs | 30 +--- .../ClawdDotNet.Tools.RocketChat.csproj | 4 + .../RocketChatTool.cs | 34 ++-- .../WorkspaceFile.cs | 43 ++--- .../ClawdDotNet.Tools.Tests.csproj | 2 + .../Workspaces/NamedWorkspaceTransferTests.cs | 155 ++++++++++++++++++ 9 files changed, 313 insertions(+), 81 deletions(-) create mode 100644 src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs create mode 100644 tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs diff --git a/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs b/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs new file mode 100644 index 0000000..76687f1 --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs @@ -0,0 +1,80 @@ +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Organisation; + +/// #718: A workspace with a member list, as one call may use it. +public sealed record NamedWorkspace(WorkspaceConfig Workspace, string Root, WorkspaceAccess Access); + +/// +/// #718: The workspaces with member lists (#684) for every tool that moves files — FileRW, +/// FTP, RocketChat — resolved in one place instead of once per tool. +/// +/// Unknown and not allowed answer the same. A name that is no workspace and a +/// workspace the agent is not a member of both give null, so an answer never reveals +/// that a workspace exists. +/// +/// personal and shared are not resolved here: they belong to each tool +/// as they always did (FileRW with its sharedAccessLevel), and running installations +/// keep what they have. +/// +public static class NamedWorkspaces +{ + /// + /// Splits name:path. A prefix counts only if it is personal, shared, + /// unit or could be a workspace id — so a colon elsewhere in a path (an NTFS stream, + /// a drive letter) is left for the path check to refuse. Without a prefix: personal. + /// + public static (string Workspace, string Relative) Split(string path) + { + var colon = path.IndexOf(':'); + if (colon > 0) + { + var prefix = path[..colon]; + var rest = path[(colon + 1)..]; + var reserved = prefix.ToLowerInvariant(); + if (WorkspaceLayout.Reserved.Contains(reserved)) + return (reserved, rest); + if (WorkspaceLayout.IsValidId(prefix)) + return (prefix, rest); + } + return (WorkspaceLayout.Personal, path); + } + + /// + /// The workspace behind unit or an id, if the agent may use it; null + /// otherwise — also for personal and shared, which the caller handles. + /// + public static NamedWorkspace? Resolve(string name, string agentId, OrgChart? organisation, string? sharedWorkspacePath) + { + if (organisation is null + || name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase) + || name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase)) + return null; + + var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase) + ? organisation.UnitWorkspace(organisation.UnitOf(agentId)?.Id) + : organisation.Workspace(name); + if (workspace is null) + return null; + + var access = organisation.AccessTo(agentId, workspace.Id); + if (access == WorkspaceAccess.None || WorkspaceLayout.PathOf(sharedWorkspacePath, workspace.Id) is not { } root) + return null; + return new NamedWorkspace(workspace, root, access); + } + + /// The names an agent can use — for the answer to a name that is not one of them. + public static IReadOnlyList Available(string agentId, OrgChart? organisation, bool withShared) + { + var names = new List { WorkspaceLayout.Personal }; + if (withShared) + names.Add(WorkspaceLayout.Shared); + if (organisation is not null) + names.AddRange(organisation.WorkspacesFor(agentId).Select(g => + g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id)); + return names; + } + + public static string Unknown(string requested, string agentId, OrgChart? organisation, bool withShared) + => $"Unknown workspace '{requested}'. You can use: {string.Join(", ", Available(agentId, organisation, withShared))}."; +} diff --git a/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj b/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj index 4567f32..579d983 100644 --- a/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj +++ b/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/ClawdDotNet.Tools.FTP/FtpTool.cs b/src/ClawdDotNet.Tools.FTP/FtpTool.cs index 4779df0..f87e0e0 100644 --- a/src/ClawdDotNet.Tools.FTP/FtpTool.cs +++ b/src/ClawdDotNet.Tools.FTP/FtpTool.cs @@ -1,5 +1,7 @@ using System.Text; using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using FluentFTP; @@ -20,7 +22,9 @@ public sealed class FTPTool : IAgentTool For localPath, the same workspace prefixes as in the FileRW tool are supported: - "personal:website/index.html" → file in the agent's personal workspace - "shared:website/index.html" → file in the shared SharedWorkspace + - "unit:report.pdf" → your unit's workspace; ":file" → a workspace you are a member of - Without prefix, the personal workspace is used. + Uploading from a workspace needs read access to it, downloading into one needs write access. Example upload: { "action": "upload", "localPath": "shared:website/index.html", "remotePath": "/index.html" } """; @@ -36,7 +40,7 @@ public sealed class FTPTool : IAgentTool }, "localPath": { "type": "string", - "description": "Path to local file with optional workspace prefix: 'personal:file.txt' (default) or 'shared:file.txt' for the SharedWorkspace. Without prefix, personal workspace is used." + "description": "Path to local file with optional workspace prefix: 'personal:file.txt' (default), 'shared:file.txt' for the SharedWorkspace, 'unit:file.txt' for your unit's workspace or ':file.txt'. Without prefix, personal workspace is used." }, "remotePath": { "type": "string", @@ -114,33 +118,37 @@ public sealed class FTPTool : IAgentTool /// "personal:datei.txt" → persönlicher Workspace + "datei.txt" /// "datei.txt" → persönlicher Workspace + "datei.txt" (Standard) /// - private (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context) + /// + /// #718: The file is written into the workspace (download). A workspace with a member list + /// then needs Write; reading from it (upload) needs Read. + /// + internal static (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context, bool write) { string basePath; - string relativePath; - string label; + var (label, relativePath) = NamedWorkspaces.Split(localPath); - if (localPath.StartsWith("shared:", StringComparison.OrdinalIgnoreCase)) + if (label == WorkspaceLayout.Shared) { - relativePath = localPath[7..]; // after "shared:" basePath = context.SharedWorkspacePath ?? throw new ArgumentException("No SharedWorkspace configured."); - label = "shared"; } - else if (localPath.StartsWith("personal:", StringComparison.OrdinalIgnoreCase)) + else if (label == WorkspaceLayout.Personal) { - relativePath = localPath[9..]; // after "personal:" basePath = context.WorkspacePath ?? throw new ArgumentException("No personal workspace configured."); - label = "personal"; } else { - // No prefix → personal workspace as default - relativePath = localPath; - basePath = context.WorkspacePath - ?? throw new ArgumentException("No personal workspace configured."); - label = "personal"; + // #718: "unit:" or a workspace id. Unknown and not allowed answer the same. + var named = NamedWorkspaces.Resolve(label, context.AgentId, context.Organisation, context.SharedWorkspacePath); + if (named is null || named.Access < (write ? WorkspaceAccess.Write : WorkspaceAccess.Read)) + throw new ArgumentException(named is null + ? NamedWorkspaces.Unknown(label, context.AgentId, context.Organisation, withShared: true) + : $"Access denied: this agent may not {(write ? "write to" : "read from")} workspace '{named.Workspace.Id}'."); + basePath = named.Root; + label = named.Workspace.Id; + if (write) + Directory.CreateDirectory(basePath); } // Config-rootPath als Override (nur wenn kein Prefix verwendet) @@ -182,7 +190,7 @@ public sealed class FTPTool : IAgentTool try { - var (localFull, wsLabel) = ResolveLocalPath(localRel, context); + var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: false); if (!File.Exists(localFull)) return ToolResult.Fail($"Local file not found: {localRel} (resolved: {localFull})"); @@ -307,7 +315,7 @@ public sealed class FTPTool : IAgentTool try { - var (localFull, wsLabel) = ResolveLocalPath(localRel, context); + var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true); var localDir = Path.GetDirectoryName(localFull); if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir); diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index 4ae5a23..2c4803f 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -238,35 +238,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase)) return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context)); - if (context.Organisation is not { } org) - return null; - - var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase) - ? org.UnitWorkspace(org.UnitOf(context.AgentId)?.Id) - : org.Workspace(name); - if (workspace is null) - return null; - - var access = org.AccessTo(context.AgentId, workspace.Id); - return access == WorkspaceAccess.None - ? null - : new Area(workspace.Id, WorkspaceLayout.PathOf(context.SharedWorkspacePath, workspace.Id), access, - NormaliseProtectedPaths(workspace.ProtectedPaths)); + // #718: the same resolution as FTP and RocketChat. + return NamedWorkspaces.Resolve(name, context.AgentId, context.Organisation, context.SharedWorkspacePath) is { } named + ? new Area(named.Workspace.Id, named.Root, named.Access, NormaliseProtectedPaths(named.Workspace.ProtectedPaths)) + : null; } /// The answer to a name that is not a workspace this agent may use — with the ones it may. private static string UnknownWorkspace(string requested, AgentToolContext context) - { - var names = new List { WorkspaceLayout.Personal }; - if (SharedAccess(context) > WorkspaceAccess.None) - names.Add(WorkspaceLayout.Shared); - if (context.Organisation is { } org) - { - names.AddRange(org.WorkspacesFor(context.AgentId).Select(g => - g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id)); - } - return $"Unknown workspace '{requested}'. You can use: {string.Join(", ", names)}."; - } + => NamedWorkspaces.Unknown(requested, context.AgentId, context.Organisation, SharedAccess(context) > WorkspaceAccess.None); /// The one shared folder, by the level the agent carries for it — as before #684. private static WorkspaceAccess SharedAccess(AgentToolContext context) diff --git a/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj b/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj index 58d5dbe..4abf760 100644 --- a/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj +++ b/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs b/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs index 4a05f6a..4644f3d 100644 --- a/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs +++ b/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs @@ -1,3 +1,4 @@ +using ClawdDotNet.Core.Organisation; using System.Globalization; using System.Text; using System.Text.Json; @@ -57,7 +58,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider }, "localPath": { "type": "string", - "description": "Path of the file to send in the workspace (only for send_file): \"report.pdf\" or \"personal:report.pdf\" in personal workspace, \"shared:analysis.csv\" in shared workspace." + "description": "Path of the file to send in the workspace (only for send_file): \"report.pdf\" or \"personal:report.pdf\" in personal workspace, \"shared:analysis.csv\" in shared workspace, \"unit:plan.md\" in your unit's workspace, \":file\" in a workspace you are a member of." }, "filename": { "type": "string", @@ -165,8 +166,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider "'localPath' is required — e.g. \"report.pdf\" (personal workspace) " + "or \"shared:analysis.csv\" (shared workspace)."); - var (file, pathError) = WorkspaceFile.Resolve( - localPath, context.WorkspacePath, context.SharedWorkspacePath); + var (file, pathError) = WorkspaceFile.Resolve(localPath, context, write: false); if (file is null) return ToolResult.Fail(pathError!); @@ -226,23 +226,18 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider // Target name: the model's choice, otherwise the name from the message. Both are // controlled by someone else, so only the bare file name is taken and any path in // it is discarded before anything is resolved. - var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId; + var chosen = GetString(input, "localPath"); + var wanted = chosen ?? GetString(input, "filename") ?? fileId; // Strip the workspace prefix first: on Windows ':' is not a path separator but the // marker for NTFS alternate data streams. If "shared:" stayed in the name, // Path.GetFileName would pass it through unchanged — and the file would end up as // a data stream on the directory instead of a file of its own. - var prefix = ""; - var rest = wanted; - if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase)) - { - prefix = "shared:"; - rest = rest[7..]; - } - else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase)) - { - rest = rest[9..]; - } + // #718: only the model's own localPath chooses the workspace. A name from the message + // is someone else's — "unit:x.pdf" there is a file name, not a destination. + var (workspace, rest) = chosen is not null + ? NamedWorkspaces.Split(wanted) + : (WorkspaceLayout.Personal, wanted.Replace(':', '_')); var targetName = Path.GetFileName(rest.Replace('\\', '/')); if (string.IsNullOrWhiteSpace(targetName)) @@ -254,10 +249,9 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider $"Files of type '{extension}' are not downloaded. " + "If needed, set 'allowDangerousDownloads' in the tool configuration."); - // The model may choose the directory (personal:/shared:), but not compose the file - // name freely — hence prefix and sanitized name are kept separate. - var (file, pathError) = WorkspaceFile.Resolve( - prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath); + // The model may choose the directory (personal:/shared:/unit:/a workspace id), but not + // compose the file name freely — hence prefix and sanitized name are kept separate. + var (file, pathError) = WorkspaceFile.Resolve($"{workspace}:{targetName}", context, write: true); if (file is null) return ToolResult.Fail(pathError!); @@ -269,7 +263,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider { downloaded = true, fileId, - savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}", + savedAs = file.Label == WorkspaceLayout.Personal ? targetName : $"{file.Label}:{targetName}", workspace = file.Label, bytes })); diff --git a/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs b/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs index 7cd81d0..ca82845 100644 --- a/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs +++ b/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs @@ -1,3 +1,7 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Tools; + namespace ClawdDotNet.Tools.RocketChat; /// @@ -13,30 +17,31 @@ internal static class WorkspaceFile { public sealed record Resolved(string FullPath, string Label); - public static (Resolved? File, string? Error) Resolve( - string path, string? personalRoot, string? sharedRoot) + /// + /// #718: The file is written into the workspace (download). A workspace with a member list + /// then needs Write; sending a file from it needs Read. + /// + public static (Resolved? File, string? Error) Resolve(string path, AgentToolContext context, bool write) { - string relative; string? root; - string label; + var (label, relative) = NamedWorkspaces.Split(path); - if (path.StartsWith("shared:", StringComparison.OrdinalIgnoreCase)) - { - relative = path[7..]; - root = sharedRoot; - label = "shared"; - } - else if (path.StartsWith("personal:", StringComparison.OrdinalIgnoreCase)) - { - relative = path[9..]; - root = personalRoot; - label = "personal"; - } + if (label == WorkspaceLayout.Shared) + root = context.SharedWorkspacePath; + else if (label == WorkspaceLayout.Personal) + root = context.WorkspacePath; else { - relative = path; - root = personalRoot; - label = "personal"; + // #718: "unit:" or a workspace id. Unknown and not allowed answer the same. + var named = NamedWorkspaces.Resolve(label, context.AgentId, context.Organisation, context.SharedWorkspacePath); + if (named is null) + return (null, NamedWorkspaces.Unknown(label, context.AgentId, context.Organisation, withShared: true)); + if (named.Access < (write ? WorkspaceAccess.Write : WorkspaceAccess.Read)) + return (null, $"Access denied: this agent may not {(write ? "write to" : "read from")} workspace '{named.Workspace.Id}'."); + root = named.Root; + label = named.Workspace.Id; + if (write) + Directory.CreateDirectory(root); } if (string.IsNullOrWhiteSpace(root)) diff --git a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj index 49be25f..325cb44 100644 --- a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj +++ b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj @@ -23,6 +23,8 @@ + + diff --git a/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs b/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs new file mode 100644 index 0000000..d8abfaf --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs @@ -0,0 +1,155 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.FTP; +using ClawdDotNet.Tools.RocketChat; +using ClawdDotNet.Tools.Tests.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Workspaces; + +/// +/// #718: FTP and RocketChat know the workspaces with member lists (#684) the way FileRW does. +/// A buyer uploads from its unit's workspace without first copying the file to shared — +/// where the separation between units does not hold. +/// +public sealed class NamedWorkspaceTransferTests : IDisposable +{ + private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents"); + + private readonly OrgChart _org = new( + [new() { Id = "purchasing", Name = "Purchasing" }, new() { Id = "marketing", Name = "Marketing" }, new() { Id = "accounting", Name = "Accounting" }], + [ + new() { AgentId = "buyer", UnitId = "purchasing" }, + new() { AgentId = "ads", UnitId = "marketing" }, + new() { AgentId = "bookkeeper", UnitId = "accounting" } + ], + [ + new() { Id = "purchasing", Name = "purchasing", OwnerUnitId = "purchasing", Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Write }] }, + new() { Id = "accounting", Name = "accounting", OwnerUnitId = "accounting", Members = [new() { UnitId = "accounting", Access = WorkspaceAccess.Write }] }, + new() + { + Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", + Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage }, new() { UnitId = "marketing", Access = WorkspaceAccess.Read }] + } + ]); + + public NamedWorkspaceTransferTests() => Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace")); + + public void Dispose() + { + try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); } + catch { /* cleaning up is secondary */ } + } + + private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id); + + private AgentToolContext Context(string agentId) + { + var personal = Path.Combine(_agents, agentId, "Workspace"); + Directory.CreateDirectory(personal); + return new AgentToolContext(agentId, "inst", new Dictionary(), new NullStateStore(), + NullLogger.Instance, CancellationToken.None, WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace")) + { + Organisation = _org + }; + } + + // ═══════════════════ the prefix ═══════════════════ + + [Theory] + [InlineData("report.pdf", "personal", "report.pdf")] + [InlineData("personal:a/b.txt", "personal", "a/b.txt")] + [InlineData("SHARED:x.csv", "shared", "x.csv")] + [InlineData("unit:orders.md", "unit", "orders.md")] + [InlineData("articles:list.csv", "articles", "list.csv")] + [InlineData("bericht.txt:hidden", "personal", "bericht.txt:hidden")] // an NTFS stream stays for the path check + public void The_prefix_names_the_workspace_only_when_it_can_be_one(string path, string workspace, string relative) + => NamedWorkspaces.Split(path).ShouldBe((workspace, relative)); + + // ═══════════════════ FTP ═══════════════════ + + [Fact] + public void Ftp_uploads_from_the_own_units_workspace() + { + Directory.CreateDirectory(Area("purchasing")); + File.WriteAllText(Path.Combine(Area("purchasing"), "orders.csv"), "x"); + + var (path, label) = FTPTool.ResolveLocalPath("unit:orders.csv", Context("buyer"), write: false); + + path.ShouldBe(Path.Combine(Area("purchasing"), "orders.csv")); + label.ShouldBe("purchasing"); + } + + [Fact] + public void Ftp_reads_a_workspace_with_read_access_but_does_not_download_into_it() + { + FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: false).fullPath + .ShouldBe(Path.Combine(Area("articles"), "list.csv")); + + Should.Throw(() => FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: true)) + .Message.ShouldContain("may not write"); + } + + [Fact] + public void Ftp_answers_a_foreign_workspace_like_an_unknown_one() + { + var foreign = Should.Throw(() => FTPTool.ResolveLocalPath("accounting:books.csv", Context("buyer"), write: false)).Message; + var unknown = Should.Throw(() => FTPTool.ResolveLocalPath("nowhere:books.csv", Context("buyer"), write: false)).Message; + + foreign.ShouldBe(unknown.Replace("nowhere", "accounting")); + foreign.ShouldNotContain("accounting (="); + } + + [Fact] + public void Ftp_still_refuses_a_way_out_of_the_workspace() + => Should.Throw(() => FTPTool.ResolveLocalPath("unit:../accounting/books.csv", Context("buyer"), write: false)); + + [Fact] + public void Ftp_keeps_personal_and_shared_as_they_were() + { + FTPTool.ResolveLocalPath("notes.txt", Context("buyer"), write: true).workspaceLabel.ShouldBe("personal"); + FTPTool.ResolveLocalPath("shared:site/index.html", Context("buyer"), write: true).workspaceLabel.ShouldBe("shared"); + } + + // ═══════════════════ RocketChat ═══════════════════ + + [Fact] + public void RocketChat_sends_from_and_saves_into_the_own_units_workspace() + { + var (send, error) = WorkspaceFile.Resolve("unit:plan.md", Context("buyer"), write: false); + error.ShouldBeNull(); + send!.FullPath.ShouldBe(Path.Combine(Area("purchasing"), "plan.md")); + + var (save, _) = WorkspaceFile.Resolve("unit:reply.pdf", Context("buyer"), write: true); + save!.Label.ShouldBe("purchasing"); + Directory.Exists(Area("purchasing")).ShouldBeTrue("a workspace comes into being when it is first written to"); + } + + [Fact] + public void RocketChat_needs_write_access_to_save_into_a_workspace() + { + var (file, error) = WorkspaceFile.Resolve("articles:offer.pdf", Context("ads"), write: true); + + file.ShouldBeNull(); + error!.ShouldContain("may not write"); + } + + [Fact] + public void RocketChat_answers_a_foreign_workspace_like_an_unknown_one() + { + var (_, foreign) = WorkspaceFile.Resolve("accounting:books.csv", Context("buyer"), write: false); + var (_, unknown) = WorkspaceFile.Resolve("nowhere:books.csv", Context("buyer"), write: false); + + foreign.ShouldBe(unknown!.Replace("nowhere", "accounting")); + } + + [Fact] + public void RocketChat_still_refuses_streams_and_ways_out() + { + WorkspaceFile.Resolve("bericht.txt:hidden", Context("buyer"), write: true).Error!.ShouldContain("Colons"); + WorkspaceFile.Resolve("unit:../../x", Context("buyer"), write: true).Error!.ShouldContain("outside"); + } +}