From 5de60c3b212f82feb9ca8c424a38b48d23ca3322 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 11:28:53 +0200 Subject: [PATCH] FileRW: Loeschen ueber den Dateiverlauf umkehrbar, ohne Freigabe; Ordner und Unaufhebbares bleiben beim Menschen (#195) Der Dateiverlauf (#719) haelt die Vorfassung jeder geloeschten Datei - ein eigener Papierkorb waere doppelt. delete einer Datei, die der Verlauf aufhebt, laeuft ohne Freigabe; Ordner nur mit recursive:true (delete_folder, Freigabe); zu grosse Dateien oder Verlauf aus (delete_unkept, Freigabe); die Wurzel nie. Neue Aktionen deleted und restore mit voller Pfad-/Endungspruefung. IAgentTool.StagingAktion bekommt eine Ueberladung mit Kontext. Co-Authored-By: Claude Opus 5.5 --- ...MSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md | 10 ++ src/ClawdDotNet.Core/Engine/AgentEngine.cs | 2 +- src/ClawdDotNet.Core/Files/FileHistory.cs | 18 +++ src/ClawdDotNet.Core/Staging/StagingPolicy.cs | 3 + src/ClawdDotNet.Core/Tools/IAgentTool.cs | 7 + src/ClawdDotNet.Tools.FileRW/FileRWTool.cs | 128 +++++++++++++++- .../FileRW/FileHistoryRecordingTests.cs | 2 +- .../FileRW/ReversibleDeleteTests.cs | 144 ++++++++++++++++++ 8 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 tests/ClawdDotNet.Tools.Tests/FileRW/ReversibleDeleteTests.cs diff --git a/docs/umsetzungsplaene/UMSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md b/docs/umsetzungsplaene/UMSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md index 276d345..5e98808 100644 --- a/docs/umsetzungsplaene/UMSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md +++ b/docs/umsetzungsplaene/UMSETZUNGSPLAN-FileRW-Papierkorb-Cleanup.md @@ -1,5 +1,15 @@ # Umsetzungsplan: FileRW-Papierkorb + Cleanup-Job +> **Überholt am 2026-10-02 (#195 umgesetzt, anders als hier geplant).** Seit #719 hebt der +> Dateiverlauf die Vorfassung jeder gelöschten Datei auf (90 Tage, bis 20 MB je Datei). Ein +> eigener `.trash`-Ordner wäre doppelt gewesen. Umgesetzt ist deshalb: `delete` einer Datei, +> die der Verlauf aufheben kann, läuft ohne Freigabe; ein Ordner nur mit `recursive: true` und +> mit Freigabe (`FileRW.delete_folder`); eine Datei, die der Verlauf nicht aufheben kann (zu +> groß, Verlauf aus), mit Freigabe (`FileRW.delete_unkept`); die Wurzel eines Arbeitsbereichs +> nie. Agenten sehen mit `deleted`, was sie zurückholen können, und holen es mit `restore` +> zurück — mit derselben Pfad- und Endungsprüfung wie ein Schreiben. Der Rest dieses Dokuments +> ist die alte Planung. + > **Stand 2026-08-23: beschlossen, noch nicht gebaut.** Eingeordnet als Punkt 3 der > [Roadmap](../Roadmap.md) — die Reihenfolge gilt dort, dieses Dokument ist der Bauplan. diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 01e21e5..e590b1d 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -1655,7 +1655,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (_stagingGate is not null) { // #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten. - var operation = tool.StagingAktion(input) ?? Staging.StagingGate.ExtractAction(arguments); + var operation = tool.StagingAktion(input, context) ?? Staging.StagingGate.ExtractAction(arguments); var intercept = await _stagingGate.InterceptAsync( agentConfig.AgentId, instanceId, runId, toolName, arguments, ct, operation, diff --git a/src/ClawdDotNet.Core/Files/FileHistory.cs b/src/ClawdDotNet.Core/Files/FileHistory.cs index 43190fa..fb8a340 100644 --- a/src/ClawdDotNet.Core/Files/FileHistory.cs +++ b/src/ClawdDotNet.Core/Files/FileHistory.cs @@ -107,6 +107,24 @@ public sealed class FileHistory public bool Covers(string fullPath) => !IsInside(Path.GetFullPath(fullPath), Path.Combine(_agentsDirectory, FolderName)); + /// + /// #195: Whether a delete of this file can be undone — the history is on, covers the file + /// and keeps content of its size. Only then is deleting it no irreversible act. + /// + public bool CanKeep(string fullPath) + { + fullPath = Path.GetFullPath(fullPath); + return Enabled && Covers(fullPath) && File.Exists(fullPath) + && new FileInfo(fullPath).Length <= (long)_settings().MaxObjectMegabytes * 1024 * 1024; + } + + /// #195: Whether the content of a version is still kept. + public bool CanKeepContent(string? hash) + => !string.IsNullOrWhiteSpace(hash) && hash.Length == 64 && hash.All(Uri.IsHexDigit) && File.Exists(ObjectPath(hash.ToLowerInvariant())); + + /// #195: How long a deleted file's content stays restorable. + public int RetentionDays => Math.Max(1, _settings().RetentionDays); + // ─── Recording ─── /// Keeps the version the file has now, before a tool changes it. diff --git a/src/ClawdDotNet.Core/Staging/StagingPolicy.cs b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs index 926ed26..322bf81 100644 --- a/src/ClawdDotNet.Core/Staging/StagingPolicy.cs +++ b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs @@ -58,6 +58,9 @@ public sealed class StagingPolicy ["Database.truncate"] = StagingDecision.Approve, ["FileRW.delete"] = StagingDecision.Approve, + // #195: FileRW names these itself — a folder at once, or a file the history cannot keep. + ["FileRW.delete_folder"] = StagingDecision.Approve, + ["FileRW.delete_unkept"] = StagingDecision.Approve, ["FTP.upload"] = StagingDecision.Approve, ["FTP.delete"] = StagingDecision.Approve, diff --git a/src/ClawdDotNet.Core/Tools/IAgentTool.cs b/src/ClawdDotNet.Core/Tools/IAgentTool.cs index ad7a5c7..fcf20f4 100644 --- a/src/ClawdDotNet.Core/Tools/IAgentTool.cs +++ b/src/ClawdDotNet.Core/Tools/IAgentTool.cs @@ -77,6 +77,13 @@ public interface IAgentTool /// string? StagingAktion(JsonElement input) => null; + /// + /// #195: The staging key with the call's context — for a key that depends on what the call + /// meets (FileRW: a file the history keeps, a folder, a file too large to keep). Default: + /// . + /// + string? StagingAktion(JsonElement input, AgentToolContext context) => StagingAktion(input); + /// /// Die Operationen, die sich in der Werkzeugkonfiguration eines Agenten erlauben oder /// verbieten lassen (#641) - dieselben Schluessel, die bzw. das diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index 96bab68..847f8e5 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -32,7 +32,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe "- workspace='shared': the instance's SHARED folder, if you have access to it.\n" + "- workspace='unit': your unit's workspace; other named workspaces you are a member of " + "are listed in the Organisation section of your instructions.\n" + - "Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" + + "Actions: read, read_many, write, append, list, delete, deleted, restore, copy, stock_add.\n" + + "'delete' of a file can be undone: the file history keeps its last version, 'deleted' lists " + + "what was deleted in a workspace and 'restore' (with 'entry') puts a file back. A folder is only " + + "deleted with 'recursive': true, and that is put to a human first — as is a file too large to keep.\n" + "'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " + "(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " + "many files individually: each individual call costs a step.\n" + @@ -49,7 +52,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe "properties": { "action": { "type": "string", - "enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"], + "enum": ["read", "read_many", "write", "append", "list", "delete", "deleted", "restore", "copy", "stock_add"], "description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base." }, "workspace": { @@ -87,6 +90,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe "type": "integer", "description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count." }, + "recursive": { + "type": "boolean", + "description": "Only for delete: true deletes a folder with everything in it. Put to a human first." + }, + "entry": { + "type": "integer", + "description": "Only for restore: the entry number that 'deleted' lists." + }, "destinationWorkspace": { "type": "string", "description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'." @@ -166,6 +177,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe "append" => await HandleAppendAsync(input, rootPath, area, context, ct), "list" => await HandleListAsync(input, rootPath, area, context, ct), "delete" => await HandleDeleteAsync(input, rootPath, area, context, ct), + "deleted" => await HandleDeletedAsync(rootPath, area, context, ct), + "restore" => await HandleRestoreAsync(input, rootPath, area, context, ct), "copy" => await HandleCopyAsync(input, rootPath, area, context, ct), "stock_add" => await HandleStockAddAsync(input, context, ct), _ => ToolResult.Fail($"Unknown action: {action}") @@ -245,6 +258,43 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe return null; } + /// #195: The keys a delete can be filed under, next to the actions — so a tool configuration can allow or forbid them. + public IReadOnlyList Operationen + => [.. Core.Tools.WerkzeugOperationen.AusSchema(InputSchema), "delete_folder", "delete_unkept"]; + + /// + /// #195: A delete is filed by what it meets. A file the history keeps can be put back, so + /// deleting it is no irreversible act; a folder at once, or a file the history cannot keep + /// (too large, history off), is — those stay with a human. + /// + public string? StagingAktion(JsonElement input, AgentToolContext context) + { + if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty("action", out var a) || a.GetString() != "delete") + return null; + if (input.TryGetProperty("recursive", out var r) && r.ValueKind == JsonValueKind.True) + return "delete_folder"; + try + { + if (WorkspaceFromPath(ref input) is not null) + return null; + var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null; + if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area) + return null; + var path = WorkspacePath.Resolve(Path.GetFullPath(root), RelativerPfad(input), area.Name); + if (Directory.Exists(path)) + return "delete_folder"; + return context.History is { } history && history.CanKeep(path) ? "delete" : "delete_unkept"; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; // the execution reports what is wrong with the path + } + } + + /// #195: With the history keeping it, deleting a file needs nobody's approval. + public Core.Staging.StagingDecision? Vorgabe(string operation, AgentToolContext context) + => operation == "delete" && context.History is not null ? Core.Staging.StagingDecision.Auto : null; + private static string Denied(string message) => message.StartsWith("Access denied", StringComparison.Ordinal) ? message : $"Access denied: {message}"; @@ -368,7 +418,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe { "read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read, "write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write, - "delete" => area.Access >= WorkspaceAccess.Manage, + "delete" or "deleted" or "restore" => area.Access >= WorkspaceAccess.Manage, _ => false }; } @@ -1055,6 +1105,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe { var path = GetAndValidatePath(input, rootPath, area, false, context); + // #195: A workspace itself is never deleted — one call with "." would empty it. + if (string.Equals(Path.TrimEndingDirectorySeparator(path), Path.TrimEndingDirectorySeparator(rootPath), StringComparison.OrdinalIgnoreCase)) + return ToolResult.Fail($"The {area.Name} workspace itself cannot be deleted — name a file or folder in it."); + var recursive = input.TryGetProperty("recursive", out var rec) && rec.ValueKind == JsonValueKind.True; + if (Directory.Exists(path) && !recursive && Directory.EnumerateFileSystemEntries(path).Any()) + return ToolResult.Fail("That is a folder with content. Delete its files one by one, or the whole folder with 'recursive': true — which is put to a human first."); + // Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin) if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area)) { @@ -1073,7 +1130,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe var change = await BeforeChangeAsync(path, area, context, ct); File.Delete(path); await AfterChangeAsync(change, FileAction.Delete, context, ct); - return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}"); + var undo = change?.HashBefore is not null && context.History is { } kept && kept.CanKeepContent(change.HashBefore) + ? $" It can be restored for {kept.RetentionDays} days: action 'deleted' lists it, 'restore' puts it back." + : ""; + return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}.{undo}"); } else if (Directory.Exists(path)) { @@ -1095,6 +1155,66 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe } } + /// #195: What was deleted in this workspace and can still be put back — newest first. + private static async Task HandleDeletedAsync(string rootPath, Area area, AgentToolContext context, CancellationToken ct) + { + if (context.History is not { } history) + return ToolResult.Fail("This instance keeps no file history — deleted files cannot be put back."); + + var entries = (await history.ListAsync(HistoryWorkspace(area, context), null, null, null, 500, ct)) + .Where(e => e.Action == FileAction.Delete && history.CanKeepContent(e.HashBefore)) + .Where(e => !File.Exists(history.FullPathOf(e.Path))) + .Take(30) + .ToList(); + if (entries.Count == 0) + return ToolResult.Ok($"[{area.Name} Workspace] Nothing deleted that could be put back."); + + var lines = entries.Select(e => + $"- entry {e.Id}: {Path.GetRelativePath(rootPath, history.FullPathOf(e.Path)).Replace('\\', '/')} — deleted {e.OccurredAtUtc:yyyy-MM-dd HH:mm} UTC by {e.AgentId ?? "?"}"); + return ToolResult.Ok($"[{area.Name} Workspace] Deleted and restorable ({entries.Count}, newest first):\n{string.Join("\n", lines)}\n" + + "Put one back with action 'restore' and its 'entry'."); + } + + /// + /// #195: Puts a deleted file back where it was. The journal's path is an input, not a truth — + /// the target is checked like any write: inside this workspace, an allowed extension, not a + /// blocked place, and nothing there now. + /// + private async Task HandleRestoreAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct) + { + if (context.History is not { } history) + return ToolResult.Fail("This instance keeps no file history — deleted files cannot be put back."); + if (!input.TryGetProperty("entry", out var e) || !e.TryGetInt64(out var id)) + return ToolResult.Fail("'entry' is required for restore — action 'deleted' lists the entries."); + if (await history.FindAsync(id, ct) is not { } entry || entry.Action != FileAction.Delete + || !string.Equals(entry.Workspace, HistoryWorkspace(area, context), StringComparison.OrdinalIgnoreCase)) + return ToolResult.Fail($"Entry {id} is no deletion in the {area.Name} workspace — action 'deleted' lists those."); + + var relative = Path.GetRelativePath(rootPath, history.FullPathOf(entry.Path)).Replace('\\', '/'); + using var probe = JsonDocument.Parse(JsonSerializer.Serialize(new { action = "write", path = relative })); + var target = GetAndValidatePath(probe.RootElement, rootPath, area, true, context); + + var fileLock = GetFileLock(target); + await fileLock.WaitAsync(ct); + try + { + if (File.Exists(target) || Directory.Exists(target)) + return ToolResult.Fail($"'{relative}' exists again — nothing is overwritten by a restore."); + var reason = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String && c.GetString() is { Length: > 0 } why + ? why : "restored by the agent"; + await history.RestoreAsync(entry, versionBefore: true, by: context.AgentId, reason: reason, [rootPath], ct); + return ToolResult.Ok($"[{area.Name} Workspace] Restored: {area.Name}:/{relative}"); + } + catch (InvalidOperationException ex) + { + return ToolResult.Fail(ex.Message); + } + finally + { + fileLock.Release(); + } + } + private async Task HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct) { var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context); diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/FileHistoryRecordingTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/FileHistoryRecordingTests.cs index 85352c3..d3cc891 100644 --- a/tests/ClawdDotNet.Tools.Tests/FileRW/FileHistoryRecordingTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/FileHistoryRecordingTests.cs @@ -81,7 +81,7 @@ public sealed class FileHistoryRecordingTests : IDisposable await Run("a", new { action = "write", workspace = "shared", path = "old/1.md", content = "one" }); await Run("a", new { action = "write", workspace = "shared", path = "old/2.md", content = "two" }); - await Run("a", new { action = "delete", workspace = "shared", path = "old" }); + await Run("a", new { action = "delete", workspace = "shared", path = "old", recursive = true }); (await Journal()).Count(e => e.Action == FileAction.Delete).ShouldBe(2); } diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/ReversibleDeleteTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/ReversibleDeleteTests.cs new file mode 100644 index 0000000..d838511 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/ReversibleDeleteTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using ClawdDotNet.Core.Files; +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.FileRW; + +/// +/// #195: The file history keeps what a delete removes, so deleting a file is no irreversible +/// act any more and needs no approval. A folder at once, or a file the history cannot keep, +/// still goes to a human; the workspace itself is never deleted; and an agent can see and undo +/// what was deleted. Test instance 2026-10-01: eleven cleanup deletes waited for the human. +/// +public sealed class ReversibleDeleteTests : IDisposable +{ + private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "rd-" + Guid.NewGuid().ToString("N")[..8]); + private readonly string _agents; + private readonly string _shared; + private readonly FileHistorySettings _settings = new(); + private readonly FileHistory _history; + private readonly FileRWTool _tool = new(); + + public ReversibleDeleteTests() + { + _agents = Path.Combine(_instance, "Agents"); + _shared = Path.Combine(_agents, "SharedWorkspace"); + Directory.CreateDirectory(_shared); + _history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")), () => _settings); + } + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_instance); + try { Directory.Delete(_instance, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private AgentToolContext Context(string agentId = "keeper", bool history = true) + { + var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace"); + Directory.CreateDirectory(personal); + return new AgentToolContext(agentId, "inst", + new Dictionary { ["sharedAccessLevel"] = "Admin", ["sharedAllowedExtensions"] = JsonSerializer.SerializeToElement(new[] { ".txt", ".md" }) }, + new NullStateStore(), NullLogger.Instance, CancellationToken.None, + WorkspacePath: personal, SharedWorkspacePath: _shared) + { + History = history ? _history : null, + RunId = "run-" + agentId + }; + } + + private Task Run(object input, AgentToolContext? context = null) + => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None); + + private string? KeyOf(object input, AgentToolContext? context = null) + => _tool.StagingAktion(JsonSerializer.SerializeToElement(input), context ?? Context()); + + private void Put(string relative, string content = "x") + { + var full = Path.Combine(_shared, relative); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + } + + [Fact] + public async Task A_kept_file_is_deleted_without_approval_and_put_back_by_the_agent() + { + Put("wetter/stunden/berlin_00.txt", "13.4 °C"); + var delete = new { action = "delete", workspace = "shared", path = "wetter/stunden/berlin_00.txt" }; + + KeyOf(delete).ShouldBe("delete"); + _tool.Vorgabe("delete", Context()).ShouldBe(StagingDecision.Auto); + + var deleted = await Run(delete); + deleted.Success.ShouldBeTrue(deleted.ErrorMessage); + deleted.Content.ShouldContain("can be restored for 90 days"); + + var list = await Run(new { action = "deleted", workspace = "shared" }); + list.Content.ShouldContain("wetter/stunden/berlin_00.txt"); + var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id; + + var restored = await Run(new { action = "restore", workspace = "shared", entry }); + restored.Success.ShouldBeTrue(restored.ErrorMessage); + File.ReadAllText(Path.Combine(_shared, "wetter/stunden/berlin_00.txt")).ShouldBe("13.4 °C"); + + (await Run(new { action = "restore", workspace = "shared", entry })).ErrorMessage.ShouldContain("exists again"); + (await Run(new { action = "deleted", workspace = "shared" })).Content.ShouldContain("Nothing deleted"); + } + + [Fact] + public async Task A_folder_needs_recursive_and_a_human_and_the_workspace_is_never_deleted() + { + Put("archiv/a.txt"); + Put("archiv/b.txt"); + + KeyOf(new { action = "delete", workspace = "shared", path = "archiv" }).ShouldBe("delete_folder"); + KeyOf(new { action = "delete", workspace = "shared", path = "archiv", recursive = true }).ShouldBe("delete_folder"); + new StagingPolicy().Decide("FileRW", "delete_folder").ShouldBe(StagingDecision.Approve); + + (await Run(new { action = "delete", workspace = "shared", path = "archiv" })).ErrorMessage.ShouldContain("recursive"); + (await Run(new { action = "delete", workspace = "shared", path = "." })).ErrorMessage.ShouldContain("workspace itself cannot be deleted"); + Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeTrue(); + + (await Run(new { action = "delete", workspace = "shared", path = "archiv", recursive = true })).Success.ShouldBeTrue(); + Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeFalse(); + } + + [Fact] + public void A_file_the_history_cannot_keep_stays_with_a_human() + { + Put("big.txt", new string('x', 2048)); + + KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }, Context(history: false)).ShouldBe("delete_unkept"); + _tool.Vorgabe("delete", Context(history: false)).ShouldBeNull("without a history, the standard rule asks a human"); + + _settings.MaxObjectMegabytes = 0; + KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }).ShouldBe("delete_unkept"); + new StagingPolicy().Decide("FileRW", "delete_unkept").ShouldBe(StagingDecision.Approve); + new StagingPolicy().Decide("FileRW", "delete").ShouldBe(StagingDecision.Approve, "without the history's word, delete stays with a human"); + } + + [Fact] + public async Task A_deletion_from_another_workspace_or_with_a_forbidden_extension_is_not_restored() + { + Put("note.txt", "shared note"); + await Run(new { action = "delete", workspace = "shared", path = "note.txt" }); + var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id; + + (await Run(new { action = "restore", workspace = "personal", entry })).ErrorMessage.ShouldContain("no deletion in the personal workspace"); + (await Run(new { action = "restore", workspace = "shared" })).ErrorMessage.ShouldContain("'entry' is required"); + + // A file whose extension the workspace no longer allows does not come back through restore. + Put("tool.exe", "MZ"); + var exe = await _history.BeforeAsync(Path.Combine(_shared, "tool.exe"), "shared", default); + File.Delete(Path.Combine(_shared, "tool.exe")); + var recorded = await _history.RecordAsync(exe, FileAction.Delete, "someone", null, default); + (await Run(new { action = "restore", workspace = "shared", entry = recorded!.Id })).Success.ShouldBeFalse(); + File.Exists(Path.Combine(_shared, "tool.exe")).ShouldBeFalse(); + } +}